mzrs-sdk 0.1.21

High-level Rust SDK for Mezon platform
Documentation
//! Embed builder for rich message cards.
//!
//! Embeds are displayed as rich cards inside messages, similar to Discord
//! embeds. Use the fluent builder pattern to construct them.

use serde::{Deserialize, Serialize};
use serde_json::Value;

// ── Types ───────────────────────────────────────────────────────────

/// Author metadata displayed at the top of an embed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmbedAuthor {
    /// Author display name.
    pub name: String,
    /// Optional icon shown next to the author name.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub icon_url: Option<String>,
    /// Optional URL the author name links to.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

/// Footer text displayed at the bottom of an embed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmbedFooter {
    /// Footer text content.
    pub text: String,
    /// Optional icon shown next to the footer text.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub icon_url: Option<String>,
}

/// An image (thumbnail or main image) inside an embed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmbedImage {
    /// Image URL.
    pub url: String,
    /// Optional width hint in pixels.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub width: Option<u32>,
    /// Optional height hint in pixels.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub height: Option<u32>,
}

/// A field inside an embed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EmbedField {
    /// Field title.
    pub name: String,
    /// Field value / description.
    pub value: String,
    /// Whether to display inline with adjacent fields.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub inline: bool,
    /// Optional interactive input component.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub inputs: Option<Value>,
    /// Maximum selectable options for multi-select fields.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_options: Option<u32>,
}

// ── Embed ───────────────────────────────────────────────────────────

/// Rich embed card displayed inside a message.
///
/// # Example
///
/// ```rust
/// use mzrs_sdk::Embed;
///
/// let embed = Embed::new()
///     .title("Build Status")
///     .description("All checks passed.")
///     .color("#00ff00")
///     .field("Duration", "2m 34s", true)
///     .footer("CI/CD Pipeline", None);
///
/// let json = embed.build();
/// assert!(json.is_object());
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Embed {
    /// Sidebar colour (hex string).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub color: Option<String>,
    /// Embed title.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    /// Title URL.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    /// Author metadata.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<EmbedAuthor>,
    /// Description text.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Thumbnail image.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub thumbnail: Option<EmbedImage>,
    /// Embed fields.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub fields: Vec<EmbedField>,
    /// Main image.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image: Option<EmbedImage>,
    /// Timestamp string.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub timestamp: Option<String>,
    /// Footer metadata.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub footer: Option<EmbedFooter>,
}

impl Embed {
    /// Create a new empty embed.
    pub fn new() -> Self {
        Self::default()
    }

    // ── Metadata ────────────────────────────────────────────────────

    /// Set the sidebar colour (hex string like `"#ff0000"`).
    pub fn color(mut self, hex: impl Into<String>) -> Self {
        self.color = Some(hex.into());
        self
    }

    /// Set the title.
    pub fn title(mut self, t: impl Into<String>) -> Self {
        self.title = Some(t.into());
        self
    }

    /// Set the title URL.
    pub fn url(mut self, u: impl Into<String>) -> Self {
        self.url = Some(u.into());
        self
    }

    /// Set the description.
    pub fn description(mut self, d: impl Into<String>) -> Self {
        self.description = Some(d.into());
        self
    }

    /// Set the timestamp.
    pub fn timestamp(mut self, ts: impl Into<String>) -> Self {
        self.timestamp = Some(ts.into());
        self
    }

    // ── Media ───────────────────────────────────────────────────────

    /// Set the main image.
    pub fn image(mut self, url: impl Into<String>) -> Self {
        self.image = Some(EmbedImage {
            url: url.into(),
            ..Default::default()
        });
        self
    }

    /// Set the main image with dimensions.
    pub fn image_sized(mut self, url: impl Into<String>, w: u32, h: u32) -> Self {
        self.image = Some(EmbedImage {
            url: url.into(),
            width: Some(w),
            height: Some(h),
        });
        self
    }

    /// Set the thumbnail image.
    pub fn thumbnail(mut self, url: impl Into<String>) -> Self {
        self.thumbnail = Some(EmbedImage {
            url: url.into(),
            ..Default::default()
        });
        self
    }

    // ── Attribution ─────────────────────────────────────────────────

    /// Set the footer text and optional icon.
    pub fn footer(mut self, text: impl Into<String>, icon_url: Option<&str>) -> Self {
        self.footer = Some(EmbedFooter {
            text: text.into(),
            icon_url: icon_url.map(|s| s.to_owned()),
        });
        self
    }

    /// Set the author metadata.
    pub fn author(
        mut self,
        name: impl Into<String>,
        icon_url: Option<&str>,
        url: Option<&str>,
    ) -> Self {
        self.author = Some(EmbedAuthor {
            name: name.into(),
            icon_url: icon_url.map(|s| s.to_owned()),
            url: url.map(|s| s.to_owned()),
        });
        self
    }

    // ── Fields ──────────────────────────────────────────────────────

    /// Add a plain text field.
    pub fn field(
        mut self,
        name: impl Into<String>,
        value: impl Into<String>,
        inline: bool,
    ) -> Self {
        self.fields.push(EmbedField {
            name: name.into(),
            value: value.into(),
            inline,
            ..Default::default()
        });
        self
    }

    // ── Build ───────────────────────────────────────────────────────

    /// Serialise the embed to a JSON [`Value`].
    pub fn build(self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

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

    #[test]
    fn embed_basic_fields() {
        let v = Embed::new()
            .title("Test")
            .description("Desc")
            .color("#ff0000")
            .build();

        assert_eq!(v["title"], "Test");
        assert_eq!(v["description"], "Desc");
        assert_eq!(v["color"], "#ff0000");
    }

    #[test]
    fn embed_with_field() {
        let v = Embed::new().field("Key", "Value", true).build();

        let fields = v["fields"].as_array().unwrap();
        assert_eq!(fields.len(), 1);
        assert_eq!(fields[0]["name"], "Key");
        assert!(fields[0]["inline"].as_bool().unwrap());
    }
}