use std::time::Duration;
use serde::Serialize;
use smbcloud_ascapi_aso::build::{Build, BuildFilter};
use smbcloud_ascapi_aso::prelude::*;
use smbcloud_ascapi_core::Client;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessingClass {
Valid,
Failed,
Pending,
}
impl ProcessingClass {
pub fn is_terminal(self) -> bool {
!matches!(self, ProcessingClass::Pending)
}
}
pub fn classify(processing_state: Option<&str>) -> ProcessingClass {
match processing_state
.map(str::trim)
.unwrap_or("")
.to_ascii_uppercase()
.as_str()
{
"VALID" => ProcessingClass::Valid,
"INVALID" | "FAILED" => ProcessingClass::Failed,
_ => ProcessingClass::Pending,
}
}
#[derive(Debug, Clone, Copy)]
pub struct AwaitConfig {
pub interval: Duration,
pub timeout: Duration,
}
impl Default for AwaitConfig {
fn default() -> Self {
Self {
interval: Duration::from_secs(30),
timeout: Duration::from_secs(30 * 60),
}
}
}
#[derive(Debug, Clone, Serialize, schemars::JsonSchema)]
pub struct AwaitOutcome {
pub build_id: String,
pub version: Option<String>,
pub processing_state: Option<String>,
pub valid: bool,
pub timed_out: bool,
pub waited_secs: u64,
}
fn outcome(build: &Build, timed_out: bool, waited: Duration) -> AwaitOutcome {
let processing_state = build.attributes.processing_state.clone();
AwaitOutcome {
build_id: build.id.clone(),
version: build.attributes.version.clone(),
valid: !timed_out && classify(processing_state.as_deref()) == ProcessingClass::Valid,
timed_out,
processing_state,
waited_secs: waited.as_secs(),
}
}
pub async fn await_processing(
client: &Client,
app_id: &str,
filter: &BuildFilter,
config: AwaitConfig,
) -> Result<AwaitOutcome, String> {
let start = std::time::Instant::now();
loop {
let builds = client
.list_builds(app_id, filter)
.await
.map_err(|error| error.to_string())?;
let build = builds
.into_iter()
.next()
.ok_or_else(|| format!("no build found for app {app_id} matching the given filter"))?;
let class = classify(build.attributes.processing_state.as_deref());
if class.is_terminal() {
return Ok(outcome(&build, false, start.elapsed()));
}
let elapsed = start.elapsed();
if elapsed >= config.timeout {
return Ok(outcome(&build, true, elapsed));
}
let remaining = config.timeout - elapsed;
tokio::time::sleep(config.interval.min(remaining)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use smbcloud_ascapi_aso::build::BuildAttributes;
use smbcloud_ascapi_core::jsonapi::Resource;
#[test]
fn classifies_terminal_and_pending_states() {
assert_eq!(classify(Some("VALID")), ProcessingClass::Valid);
assert_eq!(classify(Some("INVALID")), ProcessingClass::Failed);
assert_eq!(classify(Some("FAILED")), ProcessingClass::Failed);
assert_eq!(classify(Some("PROCESSING")), ProcessingClass::Pending);
}
#[test]
fn classifies_unknown_and_missing_as_pending() {
assert_eq!(classify(None), ProcessingClass::Pending);
assert_eq!(classify(Some("")), ProcessingClass::Pending);
assert_eq!(classify(Some("SOMETHING_NEW")), ProcessingClass::Pending);
}
#[test]
fn classify_is_case_and_whitespace_insensitive() {
assert_eq!(classify(Some(" valid ")), ProcessingClass::Valid);
assert_eq!(classify(Some("invalid")), ProcessingClass::Failed);
}
#[test]
fn only_valid_and_failed_are_terminal() {
assert!(ProcessingClass::Valid.is_terminal());
assert!(ProcessingClass::Failed.is_terminal());
assert!(!ProcessingClass::Pending.is_terminal());
}
fn build(id: &str, state: Option<&str>) -> Build {
Resource {
id: id.to_string(),
resource_type: "builds".to_string(),
attributes: BuildAttributes {
version: Some("42".to_string()),
uploaded_date: Some("2024-01-02T03:04:05Z".to_string()),
expired: Some(false),
processing_state: state.map(str::to_string),
min_os_version: Some("17.0".to_string()),
uses_non_exempt_encryption: None,
},
relationships: None,
}
}
#[test]
fn outcome_reports_valid_build() {
let out = outcome(&build("B1", Some("VALID")), false, Duration::from_secs(5));
assert_eq!(out.build_id, "B1");
assert_eq!(out.version.as_deref(), Some("42"));
assert_eq!(out.processing_state.as_deref(), Some("VALID"));
assert!(out.valid);
assert!(!out.timed_out);
assert_eq!(out.waited_secs, 5);
}
#[test]
fn outcome_reports_failed_build_as_not_valid() {
let out = outcome(&build("B2", Some("INVALID")), false, Duration::from_secs(1));
assert!(!out.valid);
assert!(!out.timed_out);
}
#[test]
fn timed_out_outcome_is_never_valid() {
let out = outcome(&build("B3", Some("VALID")), true, Duration::from_secs(1800));
assert!(out.timed_out);
assert!(!out.valid);
}
}