Skip to main content

awp_types/
business.rs

1use serde::{Deserialize, Serialize};
2
3use crate::TrustLevel;
4
5/// Structured description of a site's business domain, capabilities, and policies.
6///
7/// Parsed from a `business.toml` file and used to auto-generate discovery documents
8/// and capability manifests.
9///
10/// The core fields (`site_name`, `site_description`, `domain`, `capabilities`,
11/// `policies`) are required. All extended sections are optional with `#[serde(default)]`
12/// so existing minimal `business.toml` files continue to work.
13#[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    // --- Extended schema sections (all optional) ---
24    /// Business identity: country, languages, currency, timezone.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub business: Option<BusinessIdentity>,
27
28    /// Brand voice configuration for agent responses.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub brand_voice: Option<BrandVoice>,
31
32    /// Product catalog.
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub products: Vec<Product>,
35
36    /// Channel configuration (WhatsApp, email, website, etc.).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub channels: Option<ChannelConfig>,
39
40    /// Payment configuration (providers, auto-approve thresholds).
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub payments: Option<PaymentConfig>,
43
44    /// Support configuration (escalation contacts, hours, SLA).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub support: Option<SupportConfig>,
47
48    /// Content management configuration.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub content: Option<ContentConfig>,
51
52    /// Review platform configuration.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub reviews: Option<ReviewConfig>,
55
56    /// Outreach and follow-up configuration.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub outreach: Option<OutreachConfig>,
59}
60
61/// A business capability with access level requirements.
62#[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/// A business policy describing operational rules.
72#[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    /// Create a `BusinessContext` with only the core required fields.
81    ///
82    /// All extended sections (business identity, brand voice, products,
83    /// channels, payments, support, content, reviews, outreach) are set
84    /// to `None` / empty.
85    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/// Business identity and locale information.
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct BusinessIdentity {
113    /// Business legal or display name.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub name: Option<String>,
116    /// ISO 3166-1 alpha-2 country code (e.g. "US", "KE").
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub country: Option<String>,
119    /// Supported languages (BCP 47 tags, e.g. ["en", "sw"]).
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub languages: Vec<String>,
122    /// ISO 4217 currency code (e.g. "USD", "KES").
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub currency: Option<String>,
125    /// IANA timezone (e.g. "Africa/Nairobi", "America/New_York").
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub timezone: Option<String>,
128}
129
130/// Brand voice configuration for consistent agent responses.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct BrandVoice {
133    /// Tone descriptor (e.g. "friendly", "professional", "casual").
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub tone: Option<String>,
136    /// Default greeting message.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub greeting: Option<String>,
139    /// Escalation message when handing off to human support.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub escalation_message: Option<String>,
142}
143
144/// A product or service in the catalog.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146pub struct Product {
147    /// Stock keeping unit identifier.
148    pub sku: String,
149    /// Display name.
150    pub name: String,
151    /// Price in smallest currency unit (e.g. cents).
152    #[serde(default)]
153    pub price: u64,
154    /// Current inventory count (0 = out of stock).
155    #[serde(default)]
156    pub inventory: u64,
157    /// Searchable tags.
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub tags: Vec<String>,
160    /// Optional description.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub description: Option<String>,
163}
164
165/// Channel configuration for multi-channel delivery.
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
167pub struct ChannelConfig {
168    /// WhatsApp Business API phone number.
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub whatsapp: Option<String>,
171    /// Email address for email channel.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub email: Option<String>,
174    /// Website URL.
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub website: Option<String>,
177    /// SMS phone number.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub sms: Option<String>,
180}
181
182/// Payment provider and policy configuration.
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184pub struct PaymentConfig {
185    /// Enabled payment providers (e.g. ["stripe", "mpesa"]).
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub providers: Vec<String>,
188    /// Auto-approve threshold in smallest currency unit.
189    #[serde(default)]
190    pub auto_approve_threshold: u64,
191    /// Require owner approval above this threshold.
192    #[serde(default)]
193    pub require_approval_threshold: u64,
194}
195
196/// Support and escalation configuration.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198pub struct SupportConfig {
199    /// Escalation contact emails or phone numbers.
200    #[serde(default, skip_serializing_if = "Vec::is_empty")]
201    pub escalation_contacts: Vec<String>,
202    /// Business hours (e.g. "Mon-Fri 9:00-17:00 EAT").
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub hours: Option<String>,
205    /// SLA response time target (e.g. "4h", "24h").
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub sla: Option<String>,
208}
209
210/// Content management configuration.
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212pub struct ContentConfig {
213    /// Blog or content topics the agent can write about.
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub topics: Vec<String>,
216    /// Whether to auto-draft content.
217    #[serde(default)]
218    pub auto_draft: bool,
219    /// Delay before publishing auto-drafted content (e.g. "24h").
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub publish_delay: Option<String>,
222}
223
224/// Review platform configuration.
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub struct ReviewConfig {
227    /// Review platforms to monitor (e.g. ["google", "yelp", "trustpilot"]).
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub platforms: Vec<String>,
230    /// Auto-respond to reviews with rating at or above this threshold (1-5).
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub auto_respond_threshold: Option<u8>,
233}
234
235/// Outreach and follow-up configuration.
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237pub struct OutreachConfig {
238    /// Follow-up timing after last interaction (e.g. "48h", "7d").
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub follow_up_delay: Option<String>,
241    /// Whether consent is required before outreach.
242    #[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        // Existing minimal business.toml files should still parse
295        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        // Round-trip
390        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}