1use serde::{Deserialize, Serialize};
2
3use crate::TrustLevel;
4
5#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct BusinessContext {
15 pub site_name: String,
16 pub site_description: String,
17 pub domain: String,
18 pub capabilities: Vec<BusinessCapability>,
19 pub policies: Vec<BusinessPolicy>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub contact: Option<String>,
22
23 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub business: Option<BusinessIdentity>,
27
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub brand_voice: Option<BrandVoice>,
31
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
34 pub products: Vec<Product>,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub channels: Option<ChannelConfig>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub payments: Option<PaymentConfig>,
43
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub support: Option<SupportConfig>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub content: Option<ContentConfig>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub reviews: Option<ReviewConfig>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub outreach: Option<OutreachConfig>,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct BusinessCapability {
64 pub name: String,
65 pub description: String,
66 pub endpoint: String,
67 pub method: String,
68 pub access_level: TrustLevel,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct BusinessPolicy {
74 pub name: String,
75 pub description: String,
76 pub policy_type: String,
77}
78
79impl BusinessContext {
80 pub fn core(
86 site_name: impl Into<String>,
87 site_description: impl Into<String>,
88 domain: impl Into<String>,
89 ) -> Self {
90 Self {
91 site_name: site_name.into(),
92 site_description: site_description.into(),
93 domain: domain.into(),
94 capabilities: vec![],
95 policies: vec![],
96 contact: None,
97 business: None,
98 brand_voice: None,
99 products: vec![],
100 channels: None,
101 payments: None,
102 support: None,
103 content: None,
104 reviews: None,
105 outreach: None,
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct BusinessIdentity {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub name: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub country: Option<String>,
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub languages: Vec<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub currency: Option<String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub timezone: Option<String>,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct BrandVoice {
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub tone: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub greeting: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub escalation_message: Option<String>,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct Product {
147 pub sku: String,
149 pub name: String,
151 #[serde(default)]
153 pub price: u64,
154 #[serde(default)]
156 pub inventory: u64,
157 #[serde(default, skip_serializing_if = "Vec::is_empty")]
159 pub tags: Vec<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub description: Option<String>,
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct ChannelConfig {
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub whatsapp: Option<String>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub email: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub website: Option<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub sms: Option<String>,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct PaymentConfig {
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub providers: Vec<String>,
188 #[serde(default)]
190 pub auto_approve_threshold: u64,
191 #[serde(default)]
193 pub require_approval_threshold: u64,
194}
195
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct SupportConfig {
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
201 pub escalation_contacts: Vec<String>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub hours: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub sla: Option<String>,
208}
209
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212pub struct ContentConfig {
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
215 pub topics: Vec<String>,
216 #[serde(default)]
218 pub auto_draft: bool,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub publish_delay: Option<String>,
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub struct ReviewConfig {
227 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub platforms: Vec<String>,
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub auto_respond_threshold: Option<u8>,
233}
234
235#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237pub struct OutreachConfig {
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub follow_up_delay: Option<String>,
241 #[serde(default)]
243 pub require_consent: bool,
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn sample_context() -> BusinessContext {
251 let mut ctx = BusinessContext::core("Test Site", "A test site", "example.com");
252 ctx.capabilities = vec![BusinessCapability {
253 name: "read_data".to_string(),
254 description: "Read data".to_string(),
255 endpoint: "/api/data".to_string(),
256 method: "GET".to_string(),
257 access_level: TrustLevel::Anonymous,
258 }];
259 ctx.policies = vec![BusinessPolicy {
260 name: "privacy".to_string(),
261 description: "Privacy policy".to_string(),
262 policy_type: "privacy".to_string(),
263 }];
264 ctx.contact = Some("admin@example.com".to_string());
265 ctx
266 }
267
268 #[test]
269 fn test_json_serde_round_trip() {
270 let ctx = sample_context();
271 let json = serde_json::to_string(&ctx).unwrap();
272 let deserialized: BusinessContext = serde_json::from_str(&json).unwrap();
273 assert_eq!(ctx, deserialized);
274 }
275
276 #[test]
277 fn test_toml_serde_round_trip() {
278 let ctx = sample_context();
279 let toml_str = toml::to_string(&ctx).unwrap();
280 let deserialized: BusinessContext = toml::from_str(&toml_str).unwrap();
281 assert_eq!(ctx, deserialized);
282 }
283
284 #[test]
285 fn test_optional_contact_skipped() {
286 let mut ctx = sample_context();
287 ctx.contact = None;
288 let json = serde_json::to_string(&ctx).unwrap();
289 assert!(!json.contains("contact"));
290 }
291
292 #[test]
293 fn test_minimal_toml_backward_compatible() {
294 let toml_str = r#"
296site_name = "Minimal"
297site_description = "Minimal site"
298domain = "example.com"
299capabilities = []
300policies = []
301"#;
302 let ctx: BusinessContext = toml::from_str(toml_str).unwrap();
303 assert_eq!(ctx.site_name, "Minimal");
304 assert!(ctx.business.is_none());
305 assert!(ctx.products.is_empty());
306 assert!(ctx.payments.is_none());
307 }
308
309 #[test]
310 fn test_full_schema_toml() {
311 let toml_str = r#"
312site_name = "Full Shop"
313site_description = "A full-featured shop"
314domain = "shop.example.com"
315contact = "hello@shop.example.com"
316
317[[capabilities]]
318name = "browse"
319description = "Browse products"
320endpoint = "/api/products"
321method = "GET"
322access_level = "anonymous"
323
324[[policies]]
325name = "returns"
326description = "30-day return policy"
327policy_type = "returns"
328
329[business]
330name = "Example Corp"
331country = "KE"
332languages = ["en", "sw"]
333currency = "KES"
334timezone = "Africa/Nairobi"
335
336[brand_voice]
337tone = "friendly"
338greeting = "Karibu! How can I help you today?"
339escalation_message = "Let me connect you with our team."
340
341[[products]]
342sku = "WIDGET-001"
343name = "Premium Widget"
344price = 2500
345inventory = 100
346tags = ["electronics", "gadgets"]
347
348[channels]
349whatsapp = "+254700000000"
350email = "support@shop.example.com"
351website = "https://shop.example.com"
352
353[payments]
354providers = ["mpesa", "stripe"]
355auto_approve_threshold = 5000
356require_approval_threshold = 50000
357
358[support]
359escalation_contacts = ["manager@shop.example.com"]
360hours = "Mon-Fri 9:00-17:00 EAT"
361sla = "4h"
362
363[content]
364topics = ["product updates", "how-to guides"]
365auto_draft = true
366publish_delay = "24h"
367
368[reviews]
369platforms = ["google", "trustpilot"]
370auto_respond_threshold = 4
371
372[outreach]
373follow_up_delay = "48h"
374require_consent = true
375"#;
376 let ctx: BusinessContext = toml::from_str(toml_str).unwrap();
377 assert_eq!(ctx.site_name, "Full Shop");
378 assert_eq!(ctx.business.as_ref().unwrap().country.as_deref(), Some("KE"));
379 assert_eq!(ctx.brand_voice.as_ref().unwrap().tone.as_deref(), Some("friendly"));
380 assert_eq!(ctx.products.len(), 1);
381 assert_eq!(ctx.products[0].sku, "WIDGET-001");
382 assert_eq!(ctx.channels.as_ref().unwrap().whatsapp.as_deref(), Some("+254700000000"));
383 assert_eq!(ctx.payments.as_ref().unwrap().providers, vec!["mpesa", "stripe"]);
384 assert_eq!(ctx.support.as_ref().unwrap().sla.as_deref(), Some("4h"));
385 assert!(ctx.content.as_ref().unwrap().auto_draft);
386 assert_eq!(ctx.reviews.as_ref().unwrap().auto_respond_threshold, Some(4));
387 assert!(ctx.outreach.as_ref().unwrap().require_consent);
388
389 let toml_out = toml::to_string_pretty(&ctx).unwrap();
391 let reparsed: BusinessContext = toml::from_str(&toml_out).unwrap();
392 assert_eq!(ctx, reparsed);
393 }
394
395 #[test]
396 fn test_extended_fields_skipped_when_empty() {
397 let ctx = sample_context();
398 let json = serde_json::to_string(&ctx).unwrap();
399 assert!(!json.contains("business"));
400 assert!(!json.contains("brandVoice"));
401 assert!(!json.contains("products"));
402 assert!(!json.contains("channels"));
403 assert!(!json.contains("payments"));
404 assert!(!json.contains("support"));
405 assert!(!json.contains("content"));
406 assert!(!json.contains("reviews"));
407 assert!(!json.contains("outreach"));
408 }
409}