use super::GwPlane;
use crate::json::OValue;
use scm::airs::gateway::{
AIGatewayClient, AIGatewayWorkspaceListOptions, AIGatewayWorkspaceStatus,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ListStatus {
Active,
Archived,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ListRead {
pub plane: GwPlane,
pub status: Option<ListStatus>,
}
pub const TELEMETRY: &[ListRead] = &[
ListRead {
plane: GwPlane::Data,
status: None,
},
ListRead {
plane: GwPlane::Admin,
status: None,
},
ListRead {
plane: GwPlane::Admin,
status: Some(ListStatus::Archived),
},
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Resolved {
ByRef(String),
ByName(String),
Ambiguous(Vec<String>),
Incomplete,
NotFound,
NoSlug,
}
pub struct ListFailure {
pub error: scm::Error,
pub plane: GwPlane,
pub after_unmatched: bool,
}
pub async fn read_list(gateway: &AIGatewayClient, read: ListRead) -> Result<OValue, ListFailure> {
let failure = |error| ListFailure {
error,
plane: read.plane,
after_unmatched: false,
};
let options = super::sdk_options(crate::clients::SINGLE_REQUEST_BUDGET).map_err(failure)?;
let mut query = AIGatewayWorkspaceListOptions::default();
query.plane = read.plane.sdk();
query.status = read.status.map(|status| match status {
ListStatus::Active => AIGatewayWorkspaceStatus::Active,
ListStatus::Archived => AIGatewayWorkspaceStatus::Archived,
});
let answer = gateway
.workspaces()
.list(&query, Some(&options))
.await
.map_err(failure)?;
crate::json::page_value(&answer).map_err(failure)
}
fn slug_of(row: &OValue) -> Option<String> {
row.get("slug")
.and_then(OValue::as_str)
.filter(|slug| !slug.is_empty())
.map(str::to_owned)
}
fn exact_row<'a>(rows: &'a [OValue], reference: &str) -> Option<&'a OValue> {
let text = |row: &OValue, key: &str| row.get(key).and_then(OValue::as_str) == Some(reference);
rows.iter().find(|row| text(row, "id") || text(row, "slug"))
}
fn match_name(rows: &[OValue], reference: &str) -> Resolved {
let named: Vec<Option<String>> = rows
.iter()
.filter(|row| row.get("name").and_then(OValue::as_str) == Some(reference))
.map(slug_of)
.collect();
match named.as_slice() {
[] => Resolved::NotFound,
[Some(slug)] => Resolved::ByName(slug.clone()),
[None] => Resolved::NoSlug,
_ => Resolved::Ambiguous(
named
.into_iter()
.map(|slug| slug.unwrap_or_else(|| "(no slug)".to_owned()))
.collect(),
),
}
}
pub fn incomplete(answer: &OValue) -> bool {
let rows = answer
.get("data")
.and_then(OValue::as_array)
.map_or(0, <[OValue]>::len);
let has_more = matches!(answer.get("has_more"), Some(OValue::Bool(true)));
super::incomplete_total(rows, answer.get("total"), has_more).is_some()
}
pub fn match_answer(answer: &OValue, reference: &str) -> Resolved {
match_answers(std::slice::from_ref(answer), reference)
}
fn rows_of(answer: &OValue) -> &[OValue] {
answer
.get("data")
.and_then(OValue::as_array)
.unwrap_or_default()
}
pub fn match_answers(answers: &[OValue], reference: &str) -> Resolved {
if let Some(row) = answers
.iter()
.find_map(|answer| exact_row(rows_of(answer), reference))
{
return slug_of(row).map_or(Resolved::NoSlug, Resolved::ByRef);
}
if answers.iter().any(incomplete) {
return Resolved::Incomplete;
}
answers
.iter()
.map(|answer| match_name(rows_of(answer), reference))
.find(|found| *found != Resolved::NotFound)
.unwrap_or(Resolved::NotFound)
}
pub async fn resolve(
gateway: &AIGatewayClient,
reference: &str,
reads: &[ListRead],
) -> Result<Resolved, ListFailure> {
let mut answers = Vec::with_capacity(reads.len());
for read in reads {
let answer = read_list(gateway, *read)
.await
.map_err(|failure| ListFailure {
after_unmatched: !answers.is_empty(),
..failure
})?;
let exact = exact_row(rows_of(&answer), reference).is_some();
answers.push(answer);
if exact {
break;
}
}
Ok(match_answers(&answers, reference))
}
pub fn later_list_hint(reference: &str) -> String {
format!(
"⚠ '{}' is no workspace id or slug on the data-plane list, so the admin-plane lists were read too: use the workspace slug or UUID (a display name also reads the admin-plane lists, which need the admin grant)",
super::shown(reference)
)
}
pub fn not_found_message(reference: &str, reads: &[ListRead]) -> String {
let mut planes: Vec<&str> = Vec::new();
for read in reads {
if !planes.contains(&read.plane.name()) {
planes.push(read.plane.name());
}
}
let planes = match planes.as_slice() {
[one] => format!("{one} plane"),
many => format!("{} planes", many.join(" and ")),
};
format!(
"Workspace '{}' was not found on the {planes}; nothing was sent",
super::shown(reference)
)
}
pub fn incomplete_message(reference: &str, nothing_sent: bool) -> String {
format!(
"Workspace '{}' cannot be resolved: the workspace list is incomplete (the API offers no continuation); use the workspace slug or UUID{}",
super::shown(reference),
if nothing_sent {
"; nothing was sent"
} else {
""
}
)
}
pub fn no_slug_message(reference: &str, nothing_sent: bool) -> String {
format!(
"Workspace '{}' cannot be resolved: the matching workspace carries no slug in the list answer{}",
super::shown(reference),
if nothing_sent {
"; nothing was sent"
} else {
""
}
)
}
pub fn ambiguous_message(reference: &str, slugs: &[String]) -> String {
format!(
"workspace name '{}' is ambiguous ({}) — use a slug or UUID",
super::shown(reference),
slugs
.iter()
.map(|slug| crate::output::terminal_safe(slug).into_owned())
.collect::<Vec<_>>()
.join(", ")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::json::parse;
#[test]
fn rows_match_by_ref_then_unique_name() {
let rows = parse(
r#"{"data":[{"id":"u-dev","slug":"ws-develo-71f8d8","name":"Development"},{"id":"u-prod","slug":"ws-produc-985697","name":"Production"},{"id":"u-p2","slug":"ws-produc-111111","name":"Twin"},{"id":"u-p3","slug":"ws-produc-222222","name":"Twin"}]}"#,
)
.unwrap();
let rows = &rows;
assert_eq!(
match_answer(rows, "Development"),
Resolved::ByName("ws-develo-71f8d8".into())
);
assert_eq!(
match_answer(rows, "u-prod"),
Resolved::ByRef("ws-produc-985697".into())
);
assert_eq!(
match_answer(rows, "ws-develo-71f8d8"),
Resolved::ByRef("ws-develo-71f8d8".into())
);
assert_eq!(
match_answer(rows, "Twin"),
Resolved::Ambiguous(vec!["ws-produc-111111".into(), "ws-produc-222222".into()])
);
assert_eq!(match_answer(rows, "nope"), Resolved::NotFound);
for incomplete in [
r#"{"data":[{"id":"u-prod","slug":"ws-produc-985697","name":"Production"}],"has_more":true}"#,
r#"{"data":[{"id":"u-prod","slug":"ws-produc-985697","name":"Production"}],"total":50}"#,
] {
let answer = parse(incomplete).unwrap();
assert!(super::incomplete(&answer));
assert_eq!(match_answer(&answer, "Production"), Resolved::Incomplete);
assert_eq!(match_answer(&answer, "nope"), Resolved::Incomplete);
assert_eq!(
match_answer(&answer, "u-prod"),
Resolved::ByRef("ws-produc-985697".into())
);
assert_eq!(
match_answer(&answer, "ws-produc-985697"),
Resolved::ByRef("ws-produc-985697".into())
);
}
for complete in [
r#"{"data":[{"id":"u-prod","slug":"ws-produc-985697","name":"Production"}],"total":1,"has_more":false}"#,
r#"{"data":[{"id":"u-prod","slug":"ws-produc-985697","name":"Production"}],"total":2.5}"#,
] {
let answer = parse(complete).unwrap();
assert!(!super::incomplete(&answer));
assert_eq!(
match_answer(&answer, "Production"),
Resolved::ByName("ws-produc-985697".into())
);
}
assert_eq!(
incomplete_message("Production", true),
"Workspace 'Production' cannot be resolved: the workspace list is incomplete (the API offers no continuation); use the workspace slug or UUID; nothing was sent"
);
assert_eq!(
ambiguous_message("Twin", &["a".into(), "b".into()]),
"workspace name 'Twin' is ambiguous (a, b) — use a slug or UUID"
);
assert_eq!(
not_found_message("NoSuch", &TELEMETRY[1..]),
"Workspace 'NoSuch' was not found on the admin plane; nothing was sent"
);
assert_eq!(
not_found_message("NoSuch", TELEMETRY),
"Workspace 'NoSuch' was not found on the data and admin planes; nothing was sent"
);
}
#[test]
fn an_exact_reference_on_a_later_list_wins_over_an_earlier_name() {
let data = parse(r#"{"data":[{"id":"u-a","slug":"ws-a","name":"ws-b"}]}"#).unwrap();
let admin = parse(
r#"{"data":[{"id":"u-a","slug":"ws-a","name":"ws-b"},{"id":"u-b","slug":"ws-b","name":"B"}]}"#,
)
.unwrap();
let answers = [data.clone(), admin.clone()];
assert_eq!(
match_answers(&answers, "ws-b"),
Resolved::ByRef("ws-b".into())
);
assert_eq!(
match_answers(&answers, "u-b"),
Resolved::ByRef("ws-b".into())
);
assert_eq!(
match_answers(&answers, "B"),
Resolved::ByName("ws-b".into())
);
assert_eq!(
match_answers(std::slice::from_ref(&data), "ws-b"),
Resolved::ByName("ws-a".into())
);
let archived = parse(r#"{"data":[{"id":"u-c","slug":"ws-c","name":"B"}]}"#).unwrap();
assert_eq!(
match_answers(&[admin.clone(), archived], "B"),
Resolved::ByName("ws-b".into())
);
let partial = parse(r#"{"data":[],"has_more":true}"#).unwrap();
assert_eq!(
match_answers(&[data.clone(), partial.clone()], "ws-b"),
Resolved::Incomplete
);
assert_eq!(
match_answers(&[data, partial, admin], "u-b"),
Resolved::ByRef("ws-b".into())
);
}
#[test]
fn a_matched_row_without_a_slug_is_refused() {
for answer in [
r#"{"data":[{"id":"u-a","name":"A"}]}"#,
r#"{"data":[{"id":"u-a","slug":"","name":"A"}]}"#,
r#"{"data":[{"id":"u-a","slug":7,"name":"A"}]}"#,
] {
let answer = parse(answer).unwrap();
assert_eq!(match_answer(&answer, "u-a"), Resolved::NoSlug);
assert_eq!(match_answer(&answer, "A"), Resolved::NoSlug);
}
let twins =
parse(r#"{"data":[{"id":"u-a","name":"A"},{"id":"u-b","slug":"ws-b","name":"A"}]}"#)
.unwrap();
assert_eq!(
match_answer(&twins, "A"),
Resolved::Ambiguous(vec!["(no slug)".into(), "ws-b".into()])
);
assert_eq!(
no_slug_message("u-a", true),
"Workspace 'u-a' cannot be resolved: the matching workspace carries no slug in the list answer; nothing was sent"
);
assert_eq!(
no_slug_message("u-a", false),
"Workspace 'u-a' cannot be resolved: the matching workspace carries no slug in the list answer"
);
}
}