a3s-use-core 0.1.0

Shared typed contracts for A3S Use domains
Documentation
use std::collections::BTreeMap;
use std::fmt;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct UseSessionId(String);

impl UseSessionId {
    pub fn parse(value: impl Into<String>) -> Result<Self, UseError> {
        let value = value.into();
        if value.is_empty()
            || !value.chars().all(|character| {
                character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
            })
        {
            return Err(UseError::new(
                "use.session.invalid_id",
                "Session IDs may contain only ASCII letters, digits, '-' and '_'.",
            ));
        }
        Ok(Self(value))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RiskClass {
    Read,
    Navigate,
    Mutate,
    Submit,
    Download,
    Execute,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Artifact {
    pub path: PathBuf,
    pub media_type: String,
    pub size: u64,
    pub sha256: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Readiness {
    Ready,
    Missing,
    Broken,
    Unknown,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DomainDiagnostic {
    pub domain: String,
    pub readiness: Readiness,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<PathBuf>,
    pub message: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub suggestions: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UseError {
    pub code: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub suggestion: Option<String>,
    #[serde(default)]
    pub details: BTreeMap<String, serde_json::Value>,
}

impl UseError {
    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            code: code.into(),
            message: message.into(),
            suggestion: None,
            details: BTreeMap::new(),
        }
    }

    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
        self.suggestion = Some(suggestion.into());
        self
    }

    pub fn with_detail(
        mut self,
        name: impl Into<String>,
        value: impl Into<serde_json::Value>,
    ) -> Self {
        self.details.insert(name.into(), value.into());
        self
    }
}

impl fmt::Display for UseError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.message)
    }
}

impl std::error::Error for UseError {}

pub type UseResult<T> = Result<T, UseError>;

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

    #[test]
    fn session_ids_are_bounded_and_serializable() {
        let id = UseSessionId::parse("browser_01").unwrap();
        assert_eq!(id.as_str(), "browser_01");
        assert!(UseSessionId::parse("../escape").is_err());
        assert_eq!(serde_json::to_string(&id).unwrap(), "\"browser_01\"");
    }

    #[test]
    fn stable_error_has_machine_code_and_suggestion() {
        let error = UseError::new("use.runtime.missing", "Runtime is missing.")
            .with_suggestion("Run a3s install use/browser.");
        let value = serde_json::to_value(error).unwrap();
        assert_eq!(value["code"], "use.runtime.missing");
        assert!(value["suggestion"]
            .as_str()
            .unwrap()
            .contains("a3s install"));
    }
}