use crate::error::{
OlError, ERR_ALREADY_ENROLLED, ERR_ORG_AMBIGUOUS, ERR_ORG_CHOICE_REQUIRED,
ERR_ORG_KEY_MISMATCH, ERR_ORG_NOT_CHOSEN, ERR_ORG_NOT_FOUND, ERR_ORG_UNVERIFIED,
};
use crate::generated::types::CliAuthOrganization;
pub const ORG_ENV: &str = "OPENLATCH_ORG";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Org {
pub id: String,
pub slug: String,
pub name: String,
}
impl From<CliAuthOrganization> for Org {
fn from(o: CliAuthOrganization) -> Self {
Self {
id: o.organization_id,
slug: printable(&o.slug),
name: printable(&o.name),
}
}
}
fn printable(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrgMatchError {
NotFound,
Ambiguous(Vec<Org>),
}
pub fn requested(flag: Option<&str>) -> Option<String> {
requested_from(flag, std::env::var(ORG_ENV).ok().as_deref())
}
pub fn requested_from(flag: Option<&str>, env: Option<&str>) -> Option<String> {
flag.or(env)
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
}
pub fn resolve<'a>(value: &str, orgs: &'a [Org]) -> Result<&'a Org, OrgMatchError> {
let value = value.trim();
if let Some(org) = orgs.iter().find(|o| o.id == value) {
return Ok(org);
}
let slug = |o: &Org| caseless_eq(&o.slug, value);
let name = |o: &Org| caseless_eq(&o.name, value);
for rule in [&slug as &dyn Fn(&Org) -> bool, &name] {
let hits: Vec<&Org> = orgs.iter().filter(|o| rule(o)).collect();
match hits.as_slice() {
[] => continue,
[one] => return Ok(one),
many => {
return Err(OrgMatchError::Ambiguous(
many.iter().map(|o| (*o).clone()).collect(),
))
}
}
}
Err(OrgMatchError::NotFound)
}
pub fn names(value: &str, id: &str, slug: Option<&str>, name: &str) -> bool {
let value = value.trim();
(!id.is_empty() && id == value)
|| slug.is_some_and(|s| !s.is_empty() && caseless_eq(s, value))
|| (!name.is_empty() && caseless_eq(name, value))
}
fn caseless_eq(a: &str, b: &str) -> bool {
a.to_lowercase() == b.to_lowercase()
}
pub fn list(orgs: &[Org]) -> String {
orgs.iter()
.map(|o| format!("{} ({})", o.name, o.slug))
.collect::<Vec<_>>()
.join(", ")
}
pub fn display_name(id: &str, orgs: &[Org]) -> String {
orgs.iter()
.find(|o| o.id == id)
.map_or_else(|| id.to_string(), |o| o.name.clone())
}
pub fn match_error(value: &str, orgs: &[Org], err: &OrgMatchError) -> OlError {
match err {
OrgMatchError::NotFound => not_found(value, orgs),
OrgMatchError::Ambiguous(hits) => OlError::new(
ERR_ORG_AMBIGUOUS,
format!("\"{value}\" matches several organizations: {}", list(hits)),
)
.with_suggestion("Name the organization by its slug, e.g. `--org <slug>`."),
}
}
pub fn not_found(value: &str, orgs: &[Org]) -> OlError {
OlError::new(
ERR_ORG_NOT_FOUND,
format!(
"No organization matches \"{value}\"; yours are {}",
list(orgs)
),
)
.with_suggestion("Use one of the slugs with `--org <slug>` or `OPENLATCH_ORG`.")
}
pub fn choice_required(orgs: &[Org]) -> OlError {
OlError::new(
ERR_ORG_CHOICE_REQUIRED,
format!("You belong to {} organizations: {}", orgs.len(), list(orgs)),
)
.with_suggestion("Pass `--org <slug>` or set OPENLATCH_ORG to choose one.")
}
pub fn not_chosen(orgs: &[Org]) -> OlError {
OlError::new(
ERR_ORG_NOT_CHOSEN,
format!("No organization chosen; yours are {}", list(orgs)),
)
.with_suggestion("Re-run the command, or pass `--org <slug>`.")
}
pub fn already_enrolled(current: &str, wanted: &str) -> OlError {
OlError::new(
ERR_ALREADY_ENROLLED,
format!("This machine is connected to {current}"),
)
.with_suggestion(format!(
"To move it, run `openlatch system auth switch --org {wanted}`."
))
}
pub fn key_mismatch(key_org: &str, wanted: &str) -> OlError {
OlError::new(
ERR_ORG_KEY_MISMATCH,
format!("The API key belongs to {key_org}, not {wanted}"),
)
.with_suggestion(format!(
"Remove `--org`/OPENLATCH_ORG, or use an API key from {wanted}."
))
}
pub fn unverified() -> OlError {
OlError::new(
ERR_ORG_UNVERIFIED,
"Cannot confirm the organization while the cloud is unreachable",
)
.with_suggestion("Re-run when online, or drop `--org`/OPENLATCH_ORG.")
}
#[cfg(test)]
mod tests {
use super::*;
fn org(id: &str, slug: &str, name: &str) -> Org {
Org {
id: id.into(),
slug: slug.into(),
name: name.into(),
}
}
fn orgs() -> Vec<Org> {
vec![
org(
"11111111-0000-4000-a000-000000000001",
"openlatch",
"OpenLatch",
),
org(
"11111111-0000-4000-a000-000000000002",
"superadmin",
"SuperAdmin",
),
org("11111111-0000-4000-a000-000000000003", "acme-eu", "Acme"),
org("11111111-0000-4000-a000-000000000004", "acme-us", "acme"),
]
}
#[test]
fn org_choice_server_text_loses_its_control_characters() {
let o = Org::from(CliAuthOrganization {
name: "\u{1b}]0;x\u{7}Evil\u{1b}[31m Corp\u{9b}2J\u{7f}".into(),
organization_id: "11111111-0000-4000-a000-000000000009".into(),
role: None,
slug: "evil\u{1b}[0m".into(),
});
assert_eq!(o.name, "]0;xEvil[31m Corp2J");
assert_eq!(o.slug, "evil[0m");
assert!(!o.name.chars().chain(o.slug.chars()).any(char::is_control));
}
#[test]
fn org_choice_exact_id_matches() {
let o = orgs();
assert_eq!(
resolve("11111111-0000-4000-a000-000000000002", &o)
.unwrap()
.slug,
"superadmin"
);
}
#[test]
fn org_choice_slug_matches_in_any_case() {
let o = orgs();
assert_eq!(resolve("OpenLatch", &o).unwrap().slug, "openlatch");
assert_eq!(resolve("SUPERADMIN", &o).unwrap().slug, "superadmin");
assert_eq!(resolve("acme-us", &o).unwrap().name, "acme");
}
#[test]
fn org_choice_name_matches_in_any_case_when_no_slug_does() {
let o = vec![
org("i1", "ol", "Open Latch"),
org("i2", "sa", "Super Admin"),
];
assert_eq!(resolve("open latch", &o).unwrap().id, "i1");
assert_eq!(resolve("SUPER ADMIN", &o).unwrap().id, "i2");
}
#[test]
fn org_choice_slug_wins_over_another_orgs_name() {
let o = vec![org("i1", "alpha", "Beta"), org("i2", "beta", "Gamma")];
assert_eq!(resolve("beta", &o).unwrap().id, "i2");
}
#[test]
fn org_choice_never_matches_a_prefix_or_a_near_miss() {
let o = orgs();
for v in ["open", "openlatc", "super", "acme-", "11111111-0000"] {
assert_eq!(resolve(v, &o), Err(OrgMatchError::NotFound), "{v}");
}
}
#[test]
fn org_choice_two_name_matches_are_ambiguous() {
let o = orgs();
match resolve("ACME", &o) {
Err(OrgMatchError::Ambiguous(hits)) => {
assert_eq!(hits.len(), 2);
let e = match_error("ACME", &o, &OrgMatchError::Ambiguous(hits));
assert_eq!(e.code, ERR_ORG_AMBIGUOUS);
assert_eq!(e.exit_code(), 1);
assert!(e.message.contains("acme-eu") && e.message.contains("acme-us"));
}
other => panic!("expected ambiguity, got {other:?}"),
}
}
#[test]
fn org_choice_not_found_exits_3_and_lists_the_slugs() {
let o = orgs();
let e = match_error("nope", &o, &OrgMatchError::NotFound);
assert_eq!(e.code, ERR_ORG_NOT_FOUND);
assert_eq!(e.exit_code(), 3);
assert!(e.message.contains("\"nope\""));
assert!(e.message.contains("OpenLatch (openlatch)"));
}
#[test]
fn org_choice_flag_wins_over_the_environment() {
assert_eq!(requested_from(Some("a"), Some("b")).as_deref(), Some("a"));
assert_eq!(requested_from(None, Some("b")).as_deref(), Some("b"));
assert_eq!(requested_from(None, None), None);
assert_eq!(requested_from(Some(" "), Some("b")), None);
assert_eq!(requested_from(None, Some("")), None);
}
#[test]
fn org_choice_key_identity_matches_id_slug_or_name() {
let (id, slug, name) = ("i1", Some("openlatch"), "OpenLatch");
assert!(names("i1", id, slug, name));
assert!(names("OPENLATCH", id, slug, name));
assert!(names("openlatch", id, None, name));
assert!(!names("open", id, slug, name));
assert!(!names("I1", id, slug, name), "ids are exact");
assert!(!names("x", "", None, ""));
}
#[test]
fn org_choice_refusals_carry_their_codes() {
let o = orgs();
assert_eq!(choice_required(&o).code, ERR_ORG_CHOICE_REQUIRED);
assert!(choice_required(&o)
.message
.starts_with("You belong to 4 organizations"));
assert_eq!(not_chosen(&o).code, ERR_ORG_NOT_CHOSEN);
let e = already_enrolled("OpenLatch", "superadmin");
assert_eq!(e.code, ERR_ALREADY_ENROLLED);
assert!(e
.suggestion
.as_deref()
.unwrap()
.contains("openlatch system auth switch --org superadmin"));
let e = key_mismatch("OpenLatch", "superadmin");
assert_eq!(e.code, ERR_ORG_KEY_MISMATCH);
assert!(e.message.contains("OpenLatch") && e.message.contains("superadmin"));
assert_eq!(unverified().code, ERR_ORG_UNVERIFIED);
}
#[test]
fn org_choice_display_name_falls_back_to_the_id() {
let o = orgs();
assert_eq!(
display_name("11111111-0000-4000-a000-000000000001", &o),
"OpenLatch"
);
assert_eq!(display_name("gone", &o), "gone");
}
}