oas-document 1.1.1

OAS Identity Document types and validation for the Open Agent Specification
Documentation
// OAS Spec §5.9 — Contact Directory
//! Contact directory for OAS Identity Documents.
//!
//! The contact section is the "how to reach me" directory — phone numbers,
//! email addresses, social media profiles, websites, and messaging endpoints.
//! Each entry carries its own [`Visibility`] level for access control.

use serde::{Deserialize, Serialize};

use crate::visibility::Visibility;

/// A single contact entry in an entity's contact directory.
///
/// Each entry represents one reachable channel (phone, email, social profile, etc.)
/// with its own visibility level for access control.
///
/// # DHT Behavior
///
/// For entries where `visibility` is not `public`, the DHT-stored copy contains:
/// ```json
/// {
///   "id": "phone-1",
///   "entryType": "phone",
///   "label": "Support",
///   "visibility": "organization",
///   "available": true,
///   "value": null
/// }
/// ```
/// The actual `value` is only served via the authenticated
/// `OASPrivateEndpointService`.
///
/// # Examples
///
/// ```
/// use oas_document::contact::ContactEntry;
/// use oas_document::visibility::Visibility;
///
/// let phone = ContactEntry {
///     id: "phone-1".to_string(),
///     entry_type: "phone".to_string(),
///     value: "tel:+15551234567".to_string(),
///     label: Some("Primary".to_string()),
///     platform: None,
///     handle: None,
///     visibility: Visibility::Public,
///     verified: Some(true),
/// };
/// assert_eq!(phone.entry_type, "phone");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContactEntry {
    /// Fragment identifier (e.g., "phone-1", "email-2", "social-x").
    pub id: String,

    /// Entry type: `"phone"`, `"email"`, `"social"`, `"website"`, `"messaging"`.
    pub entry_type: String,

    /// The contact value. Format depends on type:
    /// - phone: `tel:+15551234567`
    /// - email: `mailto:agent@example.com`
    /// - social: `https://x.com/agent`
    /// - website: `https://example.com/agent`
    /// - messaging: `https://t.me/agent_bot`
    pub value: String,

    /// Human-readable label (e.g., "Primary", "Support", "Sales", "Personal").
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,

    /// Platform name for social/messaging entries (e.g., "x", "linkedin",
    /// "discord", "telegram", "github", "slack", "whatsapp", "signal").
    #[serde(skip_serializing_if = "Option::is_none")]
    pub platform: Option<String>,

    /// Handle/username for social/messaging entries (e.g., "@agent42").
    #[serde(skip_serializing_if = "Option::is_none")]
    pub handle: Option<String>,

    /// Who can see this entry's actual value.
    /// Non-public entries show only the capability flag on the DHT.
    #[serde(default)]
    pub visibility: Visibility,

    /// Whether this contact channel has been verified by the entity.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub verified: Option<bool>,
}

/// Contact directory section of an OAS Identity Document.
///
/// Contains all reachable channels for an entity, each with
/// independent visibility controls.
///
/// # Standard Entry Types
///
/// | Type        | Value Format                      | Example                         |
/// |-------------|-----------------------------------|---------------------------------|
/// | `phone`     | `tel:` URI (E.164)                | `tel:+15551234567`              |
/// | `email`     | `mailto:` URI                     | `mailto:agent@acme.com`         |
/// | `social`    | HTTPS URL to profile              | `https://x.com/agent42`         |
/// | `website`   | HTTPS URL                         | `https://acme.com/agent`        |
/// | `messaging` | HTTPS URL or protocol-specific URI| `https://t.me/agent_bot`        |
///
/// # Examples
///
/// ```
/// use oas_document::contact::{ContactSection, ContactEntry};
/// use oas_document::visibility::Visibility;
///
/// let contact = ContactSection {
///     entries: vec![
///         ContactEntry {
///             id: "email-1".to_string(),
///             entry_type: "email".to_string(),
///             value: "mailto:hello@agent.ai".to_string(),
///             label: Some("Primary".to_string()),
///             platform: None,
///             handle: None,
///             visibility: Visibility::Public,
///             verified: Some(true),
///         },
///         ContactEntry {
///             id: "social-x".to_string(),
///             entry_type: "social".to_string(),
///             value: "https://x.com/agent42".to_string(),
///             label: None,
///             platform: Some("x".to_string()),
///             handle: Some("@agent42".to_string()),
///             visibility: Visibility::Public,
///             verified: None,
///         },
///     ],
/// };
/// assert_eq!(contact.entries.len(), 2);
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContactSection {
    /// All contact entries for this entity.
    pub entries: Vec<ContactEntry>,
}

/// Standard contact entry types.
pub mod entry_types {
    /// Telephone number (value format: `tel:` URI).
    pub const PHONE: &str = "phone";
    /// Email address (value format: `mailto:` URI).
    pub const EMAIL: &str = "email";
    /// Social media profile (value: HTTPS URL).
    pub const SOCIAL: &str = "social";
    /// Website (value: HTTPS URL).
    pub const WEBSITE: &str = "website";
    /// Messaging endpoint (value: HTTPS URL or protocol-specific URI).
    pub const MESSAGING: &str = "messaging";
}

/// Standard social platform identifiers.
pub mod platforms {
    pub const X: &str = "x";
    pub const LINKEDIN: &str = "linkedin";
    pub const GITHUB: &str = "github";
    pub const DISCORD: &str = "discord";
    pub const TELEGRAM: &str = "telegram";
    pub const SLACK: &str = "slack";
    pub const WHATSAPP: &str = "whatsapp";
    pub const SIGNAL: &str = "signal";
    pub const MASTODON: &str = "mastodon";
    pub const BLUESKY: &str = "bluesky";
    pub const YOUTUBE: &str = "youtube";
    pub const INSTAGRAM: &str = "instagram";
    pub const TIKTOK: &str = "tiktok";
    pub const REDDIT: &str = "reddit";
}

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

    #[test]
    fn serde_round_trip() {
        let contact = ContactSection {
            entries: vec![
                ContactEntry {
                    id: "phone-1".to_string(),
                    entry_type: entry_types::PHONE.to_string(),
                    value: "tel:+15551234567".to_string(),
                    label: Some("Primary".to_string()),
                    platform: None,
                    handle: None,
                    visibility: Visibility::Public,
                    verified: Some(true),
                },
                ContactEntry {
                    id: "social-x".to_string(),
                    entry_type: entry_types::SOCIAL.to_string(),
                    value: "https://x.com/agent42".to_string(),
                    label: None,
                    platform: Some(platforms::X.to_string()),
                    handle: Some("@agent42".to_string()),
                    visibility: Visibility::Public,
                    verified: None,
                },
                ContactEntry {
                    id: "phone-internal".to_string(),
                    entry_type: entry_types::PHONE.to_string(),
                    value: "tel:+15559876543".to_string(),
                    label: Some("Internal".to_string()),
                    platform: None,
                    handle: None,
                    visibility: Visibility::Organization,
                    verified: None,
                },
            ],
        };

        let json = serde_json::to_string_pretty(&contact).unwrap_or_default();
        let parsed: ContactSection =
            serde_json::from_str(&json).unwrap_or_else(|_| contact.clone());
        assert_eq!(contact, parsed);
    }

    #[test]
    fn visibility_defaults_to_public() {
        let json = r#"{"id":"e1","entryType":"email","value":"mailto:a@b.com"}"#;
        let entry: ContactEntry = serde_json::from_str(json).unwrap_or_else(|_| ContactEntry {
            id: String::new(),
            entry_type: String::new(),
            value: String::new(),
            label: None,
            platform: None,
            handle: None,
            visibility: Visibility::Private,
            verified: None,
        });
        assert_eq!(entry.visibility, Visibility::Public);
    }

    #[test]
    fn organization_visibility_entry() {
        let entry = ContactEntry {
            id: "phone-internal".to_string(),
            entry_type: entry_types::PHONE.to_string(),
            value: "tel:+15559876543".to_string(),
            label: Some("Internal hotline".to_string()),
            platform: None,
            handle: None,
            visibility: Visibility::Organization,
            verified: None,
        };
        assert!(entry.visibility.is_redacted_on_dht());
    }
}