Skip to main content

gsm_core/
validate.rs

1use crate::{
2    CardBlock, MessageCard, MessageEnvelope, OutKind, OutMessage, ProviderMessageEnvelope,
3    ReplyInput, SendInput, SendMetadata,
4};
5use anyhow::{Result, bail};
6use time::OffsetDateTime;
7
8/// Describes a validation failure for provider-core inputs.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ValidationIssue {
11    pub field: &'static str,
12    pub message: String,
13}
14
15impl ValidationIssue {
16    pub fn new(field: &'static str, message: impl Into<String>) -> Self {
17        Self {
18            field,
19            message: message.into(),
20        }
21    }
22}
23
24impl std::fmt::Display for ValidationIssue {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        write!(f, "{}: {}", self.field, self.message)
27    }
28}
29
30impl std::error::Error for ValidationIssue {}
31
32/// Convenience alias for validation results that do not use `anyhow`.
33pub type ValidationResult<T> = std::result::Result<T, ValidationIssue>;
34
35/// Validates the send provider-core input.
36pub fn validate_send_input(input: &SendInput) -> ValidationResult<()> {
37    if input.to.trim().is_empty() {
38        return Err(ValidationIssue::new("to", "recipient is required"));
39    }
40
41    let has_text = input
42        .text
43        .as_ref()
44        .map(|t| !t.trim().is_empty())
45        .unwrap_or(false);
46    let has_attachments = !input.attachments.is_empty();
47
48    if !has_text && !has_attachments {
49        return Err(ValidationIssue::new(
50            "content",
51            "provide text or at least one attachment",
52        ));
53    }
54
55    if let Some(text) = &input.text
56        && text.trim().is_empty()
57    {
58        return Err(ValidationIssue::new("text", "text cannot be empty"));
59    }
60
61    for (idx, att) in input.attachments.iter().enumerate() {
62        if att.name.trim().is_empty() {
63            return Err(ValidationIssue::new(
64                "attachments",
65                format!("attachment #{idx} name is empty"),
66            ));
67        }
68        if att.content_type.trim().is_empty() {
69            return Err(ValidationIssue::new(
70                "attachments",
71                format!("attachment #{idx} content_type is empty"),
72            ));
73        }
74        if att.data_base64.trim().is_empty() {
75            return Err(ValidationIssue::new(
76                "attachments",
77                format!("attachment #{idx} data_base64 is empty"),
78            ));
79        }
80    }
81
82    if let Some(meta) = &input.metadata {
83        if let Some(thread_id) = &meta.thread_id
84            && thread_id.trim().is_empty()
85        {
86            return Err(ValidationIssue::new(
87                "metadata.thread_id",
88                "thread_id cannot be empty",
89            ));
90        }
91        if let Some(reply_to) = &meta.reply_to
92            && reply_to.trim().is_empty()
93        {
94            return Err(ValidationIssue::new(
95                "metadata.reply_to",
96                "reply_to cannot be empty",
97            ));
98        }
99        for (idx, tag) in meta.tags.iter().enumerate() {
100            if tag.trim().is_empty() {
101                return Err(ValidationIssue::new(
102                    "metadata.tags",
103                    format!("tag #{idx} is empty"),
104                ));
105            }
106        }
107    }
108
109    Ok(())
110}
111
112/// Validates reply input, ensuring the reply target is present.
113pub fn validate_reply_input(input: &ReplyInput) -> ValidationResult<()> {
114    if input.reply_to.trim().is_empty() {
115        return Err(ValidationIssue::new(
116            "reply_to",
117            "reply_to message identifier is required",
118        ));
119    }
120
121    let send_like = SendInput {
122        to: input.to.clone(),
123        text: input.text.clone(),
124        attachments: input.attachments.clone(),
125        metadata: Some(SendMetadata {
126            thread_id: input.metadata.as_ref().and_then(|m| m.thread_id.clone()),
127            reply_to: Some(input.reply_to.clone()),
128            tags: input
129                .metadata
130                .as_ref()
131                .map(|m| m.tags.clone())
132                .unwrap_or_default(),
133        }),
134    };
135
136    validate_send_input(&send_like)
137}
138
139/// Normalizes a canonical message envelope (trims text and metadata).
140pub fn normalize_envelope(env: &mut ProviderMessageEnvelope) {
141    if let Some(text) = env.text.as_mut() {
142        let trimmed = text.trim();
143        if trimmed.is_empty() {
144            env.text = None;
145        } else {
146            *text = trimmed.to_string();
147        }
148    }
149
150    if let Some(user) = env.user_id.as_mut() {
151        let trimmed = user.trim();
152        if trimmed.is_empty() {
153            env.user_id = None;
154        } else {
155            *user = trimmed.to_string();
156        }
157    }
158
159    let mut normalized = std::collections::BTreeMap::new();
160    for (k, v) in env.metadata.iter() {
161        let key = k.trim();
162        let value = v.trim();
163        if key.is_empty() || value.is_empty() {
164            continue;
165        }
166        normalized.insert(key.to_string(), value.to_string());
167    }
168    env.metadata = normalized;
169}
170
171/// Validates an inbound [`MessageEnvelope`] for required fields and timestamp correctness.
172///
173/// ```
174/// use gsm_core::{validate_envelope, MessageEnvelope, Platform};
175/// use std::collections::BTreeMap;
176///
177/// let env = MessageEnvelope {
178///     tenant: "acme".into(),
179///     platform: Platform::Teams,
180///     chat_id: "chat-1".into(),
181///     user_id: "user-7".into(),
182///     thread_id: None,
183///     msg_id: "msg-99".into(),
184///     text: Some("Hello".into()),
185///     timestamp: "2024-01-01T00:00:00Z".into(),
186///     context: BTreeMap::new(),
187/// };
188///
189/// validate_envelope(&env).unwrap();
190/// ```
191pub fn validate_envelope(env: &MessageEnvelope) -> Result<()> {
192    if env.tenant.trim().is_empty() {
193        bail!("tenant empty");
194    }
195    if env.chat_id.trim().is_empty() {
196        bail!("chat_id empty");
197    }
198    if env.user_id.trim().is_empty() {
199        bail!("user_id empty");
200    }
201    if env.msg_id.trim().is_empty() {
202        bail!("msg_id empty");
203    }
204    // timestamp is ISO-8601-ish; accept RFC3339
205    OffsetDateTime::parse(
206        &env.timestamp,
207        &time::format_description::well_known::Rfc3339,
208    )
209    .map_err(|e| anyhow::anyhow!("invalid timestamp: {e}"))?;
210    Ok(())
211}
212
213/// Validates an outbound [`OutMessage`] before it is sent to translators.
214///
215/// ```
216/// use gsm_core::{make_tenant_ctx, validate_out, OutKind, OutMessage, Platform};
217///
218/// let out = OutMessage {
219///     ctx: make_tenant_ctx("acme".into(), None, None),
220///     tenant: "acme".into(),
221///     platform: Platform::Telegram,
222///     chat_id: "chat-1".into(),
223///     thread_id: None,
224///     kind: OutKind::Text,
225///     text: Some("Hello".into()),
226///     message_card: None,
227///     #[cfg(feature = "adaptive-cards")]
228///     adaptive_card: None,
229///     meta: Default::default(),
230/// };
231///
232/// validate_out(&out).unwrap();
233/// ```
234pub fn validate_out(out: &OutMessage) -> Result<()> {
235    if out.tenant.trim().is_empty() {
236        bail!("tenant empty");
237    }
238    if out.chat_id.trim().is_empty() {
239        bail!("chat_id empty");
240    }
241    match out.kind {
242        OutKind::Text => {
243            if out.text.as_deref().unwrap_or("").trim().is_empty() {
244                bail!("text empty");
245            }
246        }
247        OutKind::Card => {
248            let card = out
249                .message_card
250                .as_ref()
251                .ok_or_else(|| anyhow::anyhow!("card missing"))?;
252            validate_card(card)?;
253        }
254    }
255    Ok(())
256}
257
258/// Validates the structure and content of a [`MessageCard`].
259///
260/// ```
261/// use gsm_core::{validate_card, CardAction, CardBlock, MessageCard};
262/// use serde_json::json;
263///
264/// let card = MessageCard {
265///     title: Some("Weather".into()),
266///     body: vec![
267///         CardBlock::Text { text: "Forecast".into(), markdown: false },
268///         CardBlock::Fact { label: "High".into(), value: "22C".into() },
269///     ],
270///     actions: vec![
271///         CardAction::OpenUrl {
272///             title: "Details".into(),
273///             url: "https://example.com".into(),
274///             jwt: false,
275///         },
276///         CardAction::Postback { title: "Ack".into(), data: json!({"ok": true}) },
277///     ],
278/// };
279///
280/// validate_card(&card).unwrap();
281/// ```
282pub fn validate_card(card: &MessageCard) -> Result<()> {
283    if card.body.is_empty() && card.title.as_deref().unwrap_or("").is_empty() {
284        bail!("card must have title or body");
285    }
286    for block in &card.body {
287        match block {
288            CardBlock::Text { text, .. } if text.trim().is_empty() => bail!("empty text block"),
289            CardBlock::Fact { label, value }
290                if label.trim().is_empty() || value.trim().is_empty() =>
291            {
292                bail!("empty fact")
293            }
294            CardBlock::Image { url } if url.trim().is_empty() => bail!("empty image url"),
295            _ => {}
296        }
297    }
298    Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::{CardAction, Platform, make_tenant_ctx};
305    use serde_json::json;
306    use std::collections::BTreeMap;
307
308    fn sample_envelope() -> MessageEnvelope {
309        MessageEnvelope {
310            tenant: "acme".into(),
311            platform: Platform::Teams,
312            chat_id: "chat-1".into(),
313            user_id: "user-7".into(),
314            thread_id: None,
315            msg_id: "msg-1".into(),
316            text: Some("Hello".into()),
317            timestamp: "2024-01-01T00:00:00Z".into(),
318            context: BTreeMap::new(),
319        }
320    }
321
322    fn sample_out(kind: OutKind) -> OutMessage {
323        OutMessage {
324            ctx: make_tenant_ctx("acme".into(), None, None),
325            tenant: "acme".into(),
326            platform: Platform::Telegram,
327            chat_id: "chat-1".into(),
328            thread_id: None,
329            kind,
330            text: Some("Hello".into()),
331            message_card: None,
332            #[cfg(feature = "adaptive-cards")]
333            adaptive_card: None,
334            meta: Default::default(),
335        }
336    }
337
338    #[test]
339    fn envelope_rejects_empty_tenant() {
340        let mut env = sample_envelope();
341        env.tenant = "   ".into();
342        assert!(validate_envelope(&env).is_err());
343    }
344
345    #[test]
346    fn envelope_rejects_bad_timestamp() {
347        let mut env = sample_envelope();
348        env.timestamp = "not-a-date".into();
349        assert!(validate_envelope(&env).is_err());
350    }
351
352    #[test]
353    fn out_text_requires_content() {
354        let mut out = sample_out(OutKind::Text);
355        out.text = Some("   ".into());
356        assert!(validate_out(&out).is_err());
357    }
358
359    #[test]
360    fn out_card_requires_message_card() {
361        let out = sample_out(OutKind::Card);
362        assert!(validate_out(&out).is_err());
363    }
364
365    #[test]
366    fn card_requires_body_or_title() {
367        let card = MessageCard {
368            title: None,
369            body: vec![],
370            actions: vec![],
371        };
372        assert!(validate_card(&card).is_err());
373    }
374
375    #[test]
376    fn card_rejects_empty_fact_fields() {
377        let card = MessageCard {
378            title: Some("Facts".into()),
379            body: vec![CardBlock::Fact {
380                label: " ".into(),
381                value: "".into(),
382            }],
383            actions: vec![],
384        };
385        assert!(validate_card(&card).is_err());
386    }
387
388    #[test]
389    fn card_accepts_valid_structure() {
390        let card = MessageCard {
391            title: Some("Weather".into()),
392            body: vec![
393                CardBlock::Text {
394                    text: "Sunny".into(),
395                    markdown: false,
396                },
397                CardBlock::Fact {
398                    label: "High".into(),
399                    value: "22C".into(),
400                },
401            ],
402            actions: vec![CardAction::Postback {
403                title: "Ack".into(),
404                data: json!({"ok": true}),
405            }],
406        };
407        assert!(validate_card(&card).is_ok());
408    }
409
410    #[test]
411    fn send_validation_requires_text_or_attachment() {
412        let input = SendInput {
413            to: "channel-123".into(),
414            text: None,
415            attachments: vec![],
416            metadata: None,
417        };
418        assert!(validate_send_input(&input).is_err());
419    }
420
421    #[test]
422    fn reply_validation_requires_reply_to() {
423        let input = ReplyInput {
424            to: "channel-1".into(),
425            reply_to: "   ".into(),
426            text: Some("hi".into()),
427            attachments: vec![],
428            metadata: None,
429        };
430        assert!(validate_reply_input(&input).is_err());
431    }
432
433    #[test]
434    fn normalize_envelope_trims_and_prunes() {
435        let mut env = ProviderMessageEnvelope {
436            id: "id-1".into(),
437            tenant: make_tenant_ctx("acme".into(), None, None),
438            channel: "channel".into(),
439            session_id: "session".into(),
440            reply_scope: None,
441            user_id: Some("  ".into()),
442            correlation_id: None,
443            text: Some(" hi  ".into()),
444            attachments: vec![],
445            metadata: BTreeMap::from_iter([(" key ".into(), " value ".into())]),
446        };
447        normalize_envelope(&mut env);
448        assert_eq!(env.text.as_deref(), Some("hi"));
449        assert!(env.user_id.is_none());
450        assert!(!env.metadata.contains_key(" key "));
451        assert_eq!(env.metadata.get("key"), Some(&"value".to_string()));
452    }
453}