use serde::{Deserialize, Serialize};
use crate::TrustLevel;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BusinessContext {
pub site_name: String,
pub site_description: String,
pub domain: String,
pub capabilities: Vec<BusinessCapability>,
pub policies: Vec<BusinessPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub business: Option<BusinessIdentity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brand_voice: Option<BrandVoice>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub products: Vec<Product>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channels: Option<ChannelConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub payments: Option<PaymentConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub support: Option<SupportConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<ContentConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reviews: Option<ReviewConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outreach: Option<OutreachConfig>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BusinessCapability {
pub name: String,
pub description: String,
pub endpoint: String,
pub method: String,
pub access_level: TrustLevel,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BusinessPolicy {
pub name: String,
pub description: String,
pub policy_type: String,
}
impl BusinessContext {
pub fn core(
site_name: impl Into<String>,
site_description: impl Into<String>,
domain: impl Into<String>,
) -> Self {
Self {
site_name: site_name.into(),
site_description: site_description.into(),
domain: domain.into(),
capabilities: vec![],
policies: vec![],
contact: None,
business: None,
brand_voice: None,
products: vec![],
channels: None,
payments: None,
support: None,
content: None,
reviews: None,
outreach: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BusinessIdentity {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub country: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub languages: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub currency: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BrandVoice {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tone: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub greeting: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub escalation_message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Product {
pub sku: String,
pub name: String,
#[serde(default)]
pub price: u64,
#[serde(default)]
pub inventory: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChannelConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub whatsapp: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub website: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sms: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PaymentConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub providers: Vec<String>,
#[serde(default)]
pub auto_approve_threshold: u64,
#[serde(default)]
pub require_approval_threshold: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SupportConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub escalation_contacts: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hours: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sla: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ContentConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub topics: Vec<String>,
#[serde(default)]
pub auto_draft: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish_delay: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReviewConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub platforms: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_respond_threshold: Option<u8>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OutreachConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub follow_up_delay: Option<String>,
#[serde(default)]
pub require_consent: bool,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_context() -> BusinessContext {
let mut ctx = BusinessContext::core("Test Site", "A test site", "example.com");
ctx.capabilities = vec![BusinessCapability {
name: "read_data".to_string(),
description: "Read data".to_string(),
endpoint: "/api/data".to_string(),
method: "GET".to_string(),
access_level: TrustLevel::Anonymous,
}];
ctx.policies = vec![BusinessPolicy {
name: "privacy".to_string(),
description: "Privacy policy".to_string(),
policy_type: "privacy".to_string(),
}];
ctx.contact = Some("admin@example.com".to_string());
ctx
}
#[test]
fn test_json_serde_round_trip() {
let ctx = sample_context();
let json = serde_json::to_string(&ctx).unwrap();
let deserialized: BusinessContext = serde_json::from_str(&json).unwrap();
assert_eq!(ctx, deserialized);
}
#[test]
fn test_toml_serde_round_trip() {
let ctx = sample_context();
let toml_str = toml::to_string(&ctx).unwrap();
let deserialized: BusinessContext = toml::from_str(&toml_str).unwrap();
assert_eq!(ctx, deserialized);
}
#[test]
fn test_optional_contact_skipped() {
let mut ctx = sample_context();
ctx.contact = None;
let json = serde_json::to_string(&ctx).unwrap();
assert!(!json.contains("contact"));
}
#[test]
fn test_minimal_toml_backward_compatible() {
let toml_str = r#"
site_name = "Minimal"
site_description = "Minimal site"
domain = "example.com"
capabilities = []
policies = []
"#;
let ctx: BusinessContext = toml::from_str(toml_str).unwrap();
assert_eq!(ctx.site_name, "Minimal");
assert!(ctx.business.is_none());
assert!(ctx.products.is_empty());
assert!(ctx.payments.is_none());
}
#[test]
fn test_full_schema_toml() {
let toml_str = r#"
site_name = "Full Shop"
site_description = "A full-featured shop"
domain = "shop.example.com"
contact = "hello@shop.example.com"
[[capabilities]]
name = "browse"
description = "Browse products"
endpoint = "/api/products"
method = "GET"
access_level = "anonymous"
[[policies]]
name = "returns"
description = "30-day return policy"
policy_type = "returns"
[business]
name = "Example Corp"
country = "KE"
languages = ["en", "sw"]
currency = "KES"
timezone = "Africa/Nairobi"
[brand_voice]
tone = "friendly"
greeting = "Karibu! How can I help you today?"
escalation_message = "Let me connect you with our team."
[[products]]
sku = "WIDGET-001"
name = "Premium Widget"
price = 2500
inventory = 100
tags = ["electronics", "gadgets"]
[channels]
whatsapp = "+254700000000"
email = "support@shop.example.com"
website = "https://shop.example.com"
[payments]
providers = ["mpesa", "stripe"]
auto_approve_threshold = 5000
require_approval_threshold = 50000
[support]
escalation_contacts = ["manager@shop.example.com"]
hours = "Mon-Fri 9:00-17:00 EAT"
sla = "4h"
[content]
topics = ["product updates", "how-to guides"]
auto_draft = true
publish_delay = "24h"
[reviews]
platforms = ["google", "trustpilot"]
auto_respond_threshold = 4
[outreach]
follow_up_delay = "48h"
require_consent = true
"#;
let ctx: BusinessContext = toml::from_str(toml_str).unwrap();
assert_eq!(ctx.site_name, "Full Shop");
assert_eq!(ctx.business.as_ref().unwrap().country.as_deref(), Some("KE"));
assert_eq!(ctx.brand_voice.as_ref().unwrap().tone.as_deref(), Some("friendly"));
assert_eq!(ctx.products.len(), 1);
assert_eq!(ctx.products[0].sku, "WIDGET-001");
assert_eq!(ctx.channels.as_ref().unwrap().whatsapp.as_deref(), Some("+254700000000"));
assert_eq!(ctx.payments.as_ref().unwrap().providers, vec!["mpesa", "stripe"]);
assert_eq!(ctx.support.as_ref().unwrap().sla.as_deref(), Some("4h"));
assert!(ctx.content.as_ref().unwrap().auto_draft);
assert_eq!(ctx.reviews.as_ref().unwrap().auto_respond_threshold, Some(4));
assert!(ctx.outreach.as_ref().unwrap().require_consent);
let toml_out = toml::to_string_pretty(&ctx).unwrap();
let reparsed: BusinessContext = toml::from_str(&toml_out).unwrap();
assert_eq!(ctx, reparsed);
}
#[test]
fn test_extended_fields_skipped_when_empty() {
let ctx = sample_context();
let json = serde_json::to_string(&ctx).unwrap();
assert!(!json.contains("business"));
assert!(!json.contains("brandVoice"));
assert!(!json.contains("products"));
assert!(!json.contains("channels"));
assert!(!json.contains("payments"));
assert!(!json.contains("support"));
assert!(!json.contains("content"));
assert!(!json.contains("reviews"));
assert!(!json.contains("outreach"));
}
}