uvm_live_platform 0.8.1

Methods to connect to the unity live platform service.
Documentation
use crate::model::file::{ExtractedPathRename, FileType};
use crate::model::platform::{
    UnityReleaseDownloadArchitecture, UnityReleaseDownloadPlatform,
    UnityReleaseSkuFamily, UnityReleaseStream,
};
use crate::Size;
use derive_getters::Getters;
use serde::{Deserialize, Deserializer, Serialize};
use ssri::Integrity;
use std::str::FromStr;

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Release {
    pub version: String,
    pub product_name: String,
    pub release_date: String,
    pub release_notes: ReleaseNotes,
    pub stream: UnityReleaseStream,
    pub downloads: Vec<Download>,
    pub sku_family: UnityReleaseSkuFamily,
    pub recommended: bool,
    pub unity_hub_deep_link: String,
    pub short_revision: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub third_party_notices: Vec<UnityThirdPartyNotice>,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Download {
    #[serde(flatten)]
    pub release_file: UnityReleaseFile,
    pub platform: UnityReleaseDownloadPlatform,
    pub architecture: UnityReleaseDownloadArchitecture,
    pub modules: Vec<Module>,
    pub download_size: Size,
    pub installed_size: Size,
}

impl Download {
    pub fn iter_modules(&self) -> impl Iterator<Item = &Module> {
        ModuleIterator::new(&self.modules)
    }
}

#[derive(Debug, Deserialize, Serialize, Clone, Getters)]
#[serde(rename_all = "camelCase")]
pub struct Module {
    #[serde(rename = "__typename")]
    #[getter(skip)]
    type_name: Option<String>,
    #[serde(flatten)]
    release_file: UnityReleaseFile,
    download_url: Option<String>,
    id: String,
    #[getter(skip)]
    slug: Option<String>,
    #[getter(skip)]
    name: String,
    description: String,
    category: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    sub_modules: Vec<Module>,
    #[getter(skip)]
    #[serde(default)]
    required: bool,
    #[serde(default)]
    hidden: bool,
    #[serde(default)]
    pre_selected: bool,
    #[getter(skip)]
    destination: Option<String>,
    extracted_path_rename: Option<ExtractedPathRename>,
    #[getter(skip)]
    pub download_size: Size,
    #[getter(skip)]
    pub installed_size: Size,
    #[serde(default, deserialize_with = "null_to_empty_vec")]
    eula: Vec<Eula>,
}

impl Module {
    pub fn destination(&self) -> Option<String> {
        if &self.id == "ios" {
            self.destination
                .as_ref()
                .map(|d| format!("{}/iOSSupport", d.to_string()).to_string())
        } else {
            self.destination.clone()
        }
    }
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Eula {
    #[serde(flatten)]
    pub release_file: UnityReleaseFile,
    pub label: String,
    pub message: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UnityThirdPartyNotice {
    #[serde(flatten)]
    release_file: UnityReleaseFile,
    original_file_name: String,
}

#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct UnityReleaseFile {
    #[serde(default)]
    pub url: String,
    #[serde(default, deserialize_with = "deserialize_sri")]
    pub integrity: Option<Integrity>,
    #[serde(rename = "type")]
    pub file_type: Option<FileType>,
}

pub type ReleaseNotes = UnityReleaseFile;

fn deserialize_sri<'de, D>(deserializer: D) -> Result<Option<Integrity>, D::Error>
where
    D: Deserializer<'de>,
{
    let sri_str: Option<String> = Option::deserialize(deserializer)?;

    match sri_str {
        Some(s) if s.is_empty() => Ok(None),
        Some(s) => match Integrity::from_str(&s) {
            Ok(integrity) => Ok(Some(integrity)),
            Err(_) => Ok(None), // If parsing fails (e.g., MD5 hash), ignore it
        },
        None => Ok(None),
    }
}

fn null_to_empty_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
where
    D: Deserializer<'de>,
    T: Deserialize<'de>,
{
    let opt = Option::deserialize(deserializer)?;
    Ok(opt.unwrap_or_default())
}

pub struct ModuleIterator<'a> {
    stack: Vec<&'a Module>,
}

impl<'a> ModuleIterator<'a> {
    pub fn new(modules: &'a [Module]) -> Self {
        Self {
            stack: modules.iter().rev().collect(),
        }
    }
}

impl<'a> Iterator for ModuleIterator<'a> {
    type Item = &'a Module;

    fn next(&mut self) -> Option<Self::Item> {
        let next = self.stack.pop()?;
        self.stack.extend(next.sub_modules().iter().rev());
        Some(next)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn deserialize_integrity(json: &str) -> Option<Integrity> {
        let file: UnityReleaseFile = serde_json::from_str(json).unwrap();
        file.integrity
    }

    #[test]
    fn empty_string_integrity_deserializes_to_none() {
        let result = deserialize_integrity(r#"{"integrity": ""}"#);
        assert!(result.is_none());
    }

    #[test]
    fn null_integrity_deserializes_to_none() {
        let result = deserialize_integrity(r#"{"integrity": null}"#);
        assert!(result.is_none());
    }

    #[test]
    fn missing_integrity_field_deserializes_to_none() {
        let result = deserialize_integrity(r#"{}"#);
        assert!(result.is_none());
    }

    #[test]
    fn valid_integrity_deserializes_to_some() {
        let result = deserialize_integrity(
            r#"{"integrity": "sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="}"#,
        );
        assert!(result.is_some());
    }
}