Skip to main content

argentor_builtins/
data_validator.rs

1//! Data format validation skill for the Argentor agent framework.
2//!
3//! Provides validation for common data formats without external dependencies
4//! (uses only the `regex` crate and standard library). Inspired by the Vercel AI
5//! SDK Superagent verify tool pattern.
6//!
7//! # Supported formats
8//!
9//! - `email` — RFC 5322 basic validation
10//! - `url` — HTTP/HTTPS URL validation
11//! - `ipv4` — IPv4 address
12//! - `ipv6` — IPv6 address
13//! - `uuid` — UUID format (any version)
14//! - `phone` — International phone number (E.164-ish)
15//! - `credit_card` — Luhn algorithm validation
16//! - `date` — ISO 8601 date (YYYY-MM-DD)
17//! - `datetime` — ISO 8601 datetime
18//! - `hex_color` — Hex color code (#RGB or #RRGGBB)
19//! - `semver` — Semantic versioning
20//! - `json` — Valid JSON string
21//! - `base64` — Valid base64-encoded string
22//! - `domain` — Valid domain name
23//! - `mac_address` — MAC address (xx:xx:xx:xx:xx:xx or xx-xx-xx-xx-xx-xx)
24
25use argentor_core::{ArgentorResult, ToolCall, ToolResult};
26use argentor_skills::skill::{Skill, SkillDescriptor};
27use async_trait::async_trait;
28use base64::{engine::general_purpose, Engine as _};
29use regex::Regex;
30
31/// Skill that validates values against common data formats.
32pub struct DataValidatorSkill {
33    descriptor: SkillDescriptor,
34}
35
36impl DataValidatorSkill {
37    /// Create a new `DataValidatorSkill`.
38    pub fn new() -> Self {
39        Self {
40            descriptor: SkillDescriptor {
41                name: "data_validator".to_string(),
42                description: "Validate data against common formats: email, url, ipv4, ipv6, \
43                              uuid, phone, credit_card, date, datetime, hex_color, semver, \
44                              json, base64, domain, mac_address."
45                    .to_string(),
46                parameters_schema: serde_json::json!({
47                    "type": "object",
48                    "properties": {
49                        "format": {
50                            "type": "string",
51                            "enum": [
52                                "email", "url", "ipv4", "ipv6", "uuid", "phone",
53                                "credit_card", "date", "datetime", "hex_color",
54                                "semver", "json", "base64", "domain", "mac_address"
55                            ],
56                            "description": "The data format to validate against"
57                        },
58                        "value": {
59                            "type": "string",
60                            "description": "The value to validate"
61                        }
62                    },
63                    "required": ["format", "value"]
64                }),
65                required_capabilities: vec![],
66                requires_approval: false,
67            },
68        }
69    }
70}
71
72impl Default for DataValidatorSkill {
73    fn default() -> Self {
74        Self::new()
75    }
76}
77
78// ---------------------------------------------------------------------------
79// Validation functions
80// ---------------------------------------------------------------------------
81
82/// Build a validation result JSON value.
83fn result_json(valid: bool, format: &str, details: &str) -> serde_json::Value {
84    serde_json::json!({
85        "valid": valid,
86        "format": format,
87        "details": details,
88    })
89}
90
91fn validate_email(value: &str) -> serde_json::Value {
92    // Basic RFC 5322: local@domain, local part allows a broad set of characters
93    let re = Regex::new(
94        r"(?i)^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$",
95    );
96    match re {
97        Ok(re) if re.is_match(value) => {
98            let parts: Vec<&str> = value.splitn(2, '@').collect();
99            let domain = parts.get(1).copied().unwrap_or("");
100            result_json(
101                true,
102                "email",
103                &format!("Valid email address (domain: {domain})"),
104            )
105        }
106        _ => result_json(false, "email", "Invalid email address format"),
107    }
108}
109
110fn validate_url(value: &str) -> serde_json::Value {
111    // Must be http or https with a host
112    let re = Regex::new(r"^https?://[^\s/$.?#].[^\s]*$");
113    match re {
114        Ok(re) if re.is_match(value) => {
115            let scheme = if value.starts_with("https://") {
116                "https"
117            } else {
118                "http"
119            };
120            result_json(true, "url", &format!("Valid URL (scheme: {scheme})"))
121        }
122        _ => result_json(false, "url", "Invalid URL format (must be http or https)"),
123    }
124}
125
126fn validate_ipv4(value: &str) -> serde_json::Value {
127    let parts: Vec<&str> = value.split('.').collect();
128    if parts.len() != 4 {
129        return result_json(false, "ipv4", "IPv4 must have exactly 4 octets");
130    }
131
132    for (i, part) in parts.iter().enumerate() {
133        // No leading zeros (except "0" itself)
134        if part.len() > 1 && part.starts_with('0') {
135            return result_json(
136                false,
137                "ipv4",
138                &format!("Octet {i} has leading zeros: '{part}'"),
139            );
140        }
141        match part.parse::<u16>() {
142            Ok(n) if n <= 255 => {}
143            _ => {
144                return result_json(
145                    false,
146                    "ipv4",
147                    &format!("Octet {i} is not a valid number 0-255: '{part}'"),
148                );
149            }
150        }
151    }
152
153    result_json(true, "ipv4", "Valid IPv4 address")
154}
155
156fn validate_ipv6(value: &str) -> serde_json::Value {
157    // Use std::net for proper parsing (handles :: compression, embedded IPv4, etc.)
158    match value.parse::<std::net::Ipv6Addr>() {
159        Ok(_) => result_json(true, "ipv6", "Valid IPv6 address"),
160        Err(e) => result_json(false, "ipv6", &format!("Invalid IPv6 address: {e}")),
161    }
162}
163
164fn validate_uuid(value: &str) -> serde_json::Value {
165    let re = Regex::new(r"(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
166    match re {
167        Ok(re) if re.is_match(value) => {
168            // Detect version from the 13th character
169            let version_char = value.chars().nth(14).unwrap_or('0');
170            let version = match version_char {
171                '1' => "v1 (time-based)",
172                '2' => "v2 (DCE security)",
173                '3' => "v3 (MD5 namespace)",
174                '4' => "v4 (random)",
175                '5' => "v5 (SHA-1 namespace)",
176                '6' => "v6 (reordered time)",
177                '7' => "v7 (Unix epoch time)",
178                _ => "unknown version",
179            };
180            result_json(true, "uuid", &format!("Valid UUID ({version})"))
181        }
182        _ => result_json(false, "uuid", "Invalid UUID format"),
183    }
184}
185
186fn validate_phone(value: &str) -> serde_json::Value {
187    // E.164-ish: optional +, then 7-15 digits (spaces, dashes, dots, parens allowed
188    // as separators but we count only digits)
189    let digits: String = value.chars().filter(char::is_ascii_digit).collect();
190    let starts_valid = value.starts_with('+')
191        || value.starts_with('(')
192        || value.starts_with(|c: char| c.is_ascii_digit());
193
194    // Only allow digits, +, -, spaces, dots, parens
195    let all_valid = value
196        .chars()
197        .all(|c| c.is_ascii_digit() || "+- .()".contains(c));
198
199    if starts_valid && all_valid && (7..=15).contains(&digits.len()) {
200        result_json(
201            true,
202            "phone",
203            &format!("Valid phone number ({} digits)", digits.len()),
204        )
205    } else if digits.len() < 7 {
206        result_json(
207            false,
208            "phone",
209            &format!("Too few digits ({}, minimum 7)", digits.len()),
210        )
211    } else if digits.len() > 15 {
212        result_json(
213            false,
214            "phone",
215            &format!("Too many digits ({}, maximum 15)", digits.len()),
216        )
217    } else {
218        result_json(false, "phone", "Invalid phone number format")
219    }
220}
221
222fn validate_credit_card(value: &str) -> serde_json::Value {
223    // Strip spaces and dashes
224    let digits: String = value.chars().filter(char::is_ascii_digit).collect();
225
226    if digits.len() < 13 || digits.len() > 19 {
227        return result_json(
228            false,
229            "credit_card",
230            &format!("Invalid length ({} digits, expected 13-19)", digits.len()),
231        );
232    }
233
234    // Luhn algorithm
235    let mut sum: u32 = 0;
236    let mut double = false;
237
238    for ch in digits.chars().rev() {
239        let d = match ch.to_digit(10) {
240            Some(d) => d,
241            None => {
242                return result_json(false, "credit_card", "Contains non-digit characters");
243            }
244        };
245
246        let val = if double {
247            let doubled = d * 2;
248            if doubled > 9 {
249                doubled - 9
250            } else {
251                doubled
252            }
253        } else {
254            d
255        };
256
257        sum += val;
258        double = !double;
259    }
260
261    if sum % 10 == 0 {
262        // Detect card type by prefix
263        let card_type = if digits.starts_with('4') {
264            "Visa"
265        } else if digits.starts_with("51")
266            || digits.starts_with("52")
267            || digits.starts_with("53")
268            || digits.starts_with("54")
269            || digits.starts_with("55")
270        {
271            "Mastercard"
272        } else if digits.starts_with("34") || digits.starts_with("37") {
273            "American Express"
274        } else if digits.starts_with("6011") || digits.starts_with("65") {
275            "Discover"
276        } else {
277            "Unknown"
278        };
279        result_json(
280            true,
281            "credit_card",
282            &format!("Valid credit card number (Luhn check passed, type: {card_type})"),
283        )
284    } else {
285        result_json(
286            false,
287            "credit_card",
288            "Invalid credit card number (Luhn check failed)",
289        )
290    }
291}
292
293fn validate_date(value: &str) -> serde_json::Value {
294    let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$");
295    match re {
296        Ok(re) if re.is_match(value) => {
297            // Parse and validate ranges
298            let parts: Vec<&str> = value.split('-').collect();
299            let year: u32 = parts[0].parse().unwrap_or(0);
300            let month: u32 = parts[1].parse().unwrap_or(0);
301            let day: u32 = parts[2].parse().unwrap_or(0);
302
303            if !(1..=9999).contains(&year) {
304                return result_json(false, "date", &format!("Invalid year: {year}"));
305            }
306            if !(1..=12).contains(&month) {
307                return result_json(false, "date", &format!("Invalid month: {month}"));
308            }
309
310            let days_in_month = match month {
311                1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
312                4 | 6 | 9 | 11 => 30,
313                2 => {
314                    if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
315                        29
316                    } else {
317                        28
318                    }
319                }
320                _ => 0,
321            };
322
323            if day < 1 || day > days_in_month {
324                return result_json(
325                    false,
326                    "date",
327                    &format!("Invalid day {day} for month {month} (max: {days_in_month})"),
328                );
329            }
330
331            result_json(true, "date", "Valid ISO 8601 date")
332        }
333        _ => result_json(false, "date", "Invalid date format (expected YYYY-MM-DD)"),
334    }
335}
336
337fn validate_datetime(value: &str) -> serde_json::Value {
338    // ISO 8601 datetime: YYYY-MM-DDThh:mm:ss[.sss][Z|+/-hh:mm]
339    let re = Regex::new(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$");
340    match re {
341        Ok(re) if re.is_match(value) => {
342            // Further validate using chrono-style parsing of the date part
343            let date_part = &value[..10];
344            let date_result = validate_date(date_part);
345            if date_result["valid"] == false {
346                return result_json(
347                    false,
348                    "datetime",
349                    date_result["details"]
350                        .as_str()
351                        .unwrap_or("Invalid date portion"),
352                );
353            }
354
355            // Validate time part
356            let time_start = 11; // after 'T'
357            let time_str = &value[time_start..];
358            let time_core = if let Some(pos) = time_str.find(['.', 'Z', '+', '-']) {
359                &time_str[..pos]
360            } else {
361                time_str
362            };
363
364            let time_parts: Vec<&str> = time_core.split(':').collect();
365            if time_parts.len() == 3 {
366                let hour: u32 = time_parts[0].parse().unwrap_or(99);
367                let min: u32 = time_parts[1].parse().unwrap_or(99);
368                let sec: u32 = time_parts[2].parse().unwrap_or(99);
369
370                if hour > 23 {
371                    return result_json(false, "datetime", &format!("Invalid hour: {hour}"));
372                }
373                if min > 59 {
374                    return result_json(false, "datetime", &format!("Invalid minute: {min}"));
375                }
376                if sec > 59 {
377                    return result_json(false, "datetime", &format!("Invalid second: {sec}"));
378                }
379            }
380
381            let has_tz =
382                value.ends_with('Z') || value.contains('+') || (value.matches('-').count() > 2);
383            let tz_info = if has_tz { " with timezone" } else { " (local)" };
384
385            result_json(
386                true,
387                "datetime",
388                &format!("Valid ISO 8601 datetime{tz_info}"),
389            )
390        }
391        _ => result_json(
392            false,
393            "datetime",
394            "Invalid datetime format (expected ISO 8601: YYYY-MM-DDThh:mm:ss[.sss][Z|+hh:mm])",
395        ),
396    }
397}
398
399fn validate_hex_color(value: &str) -> serde_json::Value {
400    let re = Regex::new(r"(?i)^#([0-9a-f]{3}|[0-9a-f]{6})$");
401    match re {
402        Ok(re) if re.is_match(value) => {
403            let kind = if value.len() == 4 {
404                "shorthand (#RGB)"
405            } else {
406                "full (#RRGGBB)"
407            };
408            result_json(true, "hex_color", &format!("Valid hex color ({kind})"))
409        }
410        _ => result_json(
411            false,
412            "hex_color",
413            "Invalid hex color (expected #RGB or #RRGGBB)",
414        ),
415    }
416}
417
418fn validate_semver(value: &str) -> serde_json::Value {
419    // Semantic versioning: MAJOR.MINOR.PATCH[-prerelease][+build]
420    let re = Regex::new(
421        r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$",
422    );
423    match re {
424        Ok(re) if re.is_match(value) => {
425            let core_part = value.split('-').next().unwrap_or(value);
426            let core_part = core_part.split('+').next().unwrap_or(core_part);
427            let has_pre = value.contains('-');
428            let has_build = value.contains('+');
429            let mut details = format!("Valid semver ({core_part})");
430            if has_pre {
431                details.push_str(" with pre-release");
432            }
433            if has_build {
434                details.push_str(" with build metadata");
435            }
436            result_json(true, "semver", &details)
437        }
438        _ => result_json(
439            false,
440            "semver",
441            "Invalid semver (expected MAJOR.MINOR.PATCH[-prerelease][+build])",
442        ),
443    }
444}
445
446fn validate_json(value: &str) -> serde_json::Value {
447    match serde_json::from_str::<serde_json::Value>(value) {
448        Ok(parsed) => {
449            let kind = match &parsed {
450                serde_json::Value::Object(_) => "object",
451                serde_json::Value::Array(_) => "array",
452                serde_json::Value::String(_) => "string",
453                serde_json::Value::Number(_) => "number",
454                serde_json::Value::Bool(_) => "boolean",
455                serde_json::Value::Null => "null",
456            };
457            result_json(true, "json", &format!("Valid JSON ({kind})"))
458        }
459        Err(e) => result_json(false, "json", &format!("Invalid JSON: {e}")),
460    }
461}
462
463fn validate_base64(value: &str) -> serde_json::Value {
464    if value.is_empty() {
465        return result_json(true, "base64", "Valid base64 (empty string)");
466    }
467
468    // Standard base64: A-Z, a-z, 0-9, +, /, with = padding
469    let re = Regex::new(r"^[A-Za-z0-9+/]*={0,2}$");
470    match re {
471        Ok(re) if re.is_match(value) && value.len() % 4 == 0 => {
472            // Try actual decoding to be sure
473            match general_purpose::STANDARD.decode(value) {
474                Ok(decoded) => result_json(
475                    true,
476                    "base64",
477                    &format!("Valid base64 ({} bytes decoded)", decoded.len()),
478                ),
479                Err(e) => result_json(false, "base64", &format!("Invalid base64: {e}")),
480            }
481        }
482        _ => result_json(
483            false,
484            "base64",
485            "Invalid base64 encoding (bad characters or padding)",
486        ),
487    }
488}
489
490fn validate_domain(value: &str) -> serde_json::Value {
491    // Domain: labels separated by dots, each 1-63 chars, total max 253
492    if value.is_empty() || value.len() > 253 {
493        return result_json(
494            false,
495            "domain",
496            "Invalid domain (empty or exceeds 253 characters)",
497        );
498    }
499
500    let re = Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z]{2,}$");
501    match re {
502        Ok(re) if re.is_match(value) => {
503            // Validate individual label lengths
504            for label in value.split('.') {
505                if label.len() > 63 {
506                    return result_json(
507                        false,
508                        "domain",
509                        &format!("Label '{}...' exceeds 63 characters", &label[..20]),
510                    );
511                }
512            }
513
514            let label_count = value.split('.').count();
515            let tld = value.rsplit('.').next().unwrap_or("");
516            result_json(
517                true,
518                "domain",
519                &format!("Valid domain ({label_count} labels, TLD: {tld})"),
520            )
521        }
522        _ => result_json(false, "domain", "Invalid domain name format"),
523    }
524}
525
526fn validate_mac_address(value: &str) -> serde_json::Value {
527    // Accept xx:xx:xx:xx:xx:xx or xx-xx-xx-xx-xx-xx (case-insensitive hex)
528    let re = Regex::new(r"(?i)^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$");
529    match re {
530        Ok(re) if re.is_match(value) => {
531            let separator = if value.contains(':') { "colon" } else { "dash" };
532            result_json(
533                true,
534                "mac_address",
535                &format!("Valid MAC address ({separator}-separated)"),
536            )
537        }
538        _ => result_json(
539            false,
540            "mac_address",
541            "Invalid MAC address (expected xx:xx:xx:xx:xx:xx or xx-xx-xx-xx-xx-xx)",
542        ),
543    }
544}
545
546// ---------------------------------------------------------------------------
547// Skill implementation
548// ---------------------------------------------------------------------------
549
550#[async_trait]
551impl Skill for DataValidatorSkill {
552    fn descriptor(&self) -> &SkillDescriptor {
553        &self.descriptor
554    }
555
556    async fn execute(&self, call: ToolCall) -> ArgentorResult<ToolResult> {
557        let format = call.arguments["format"]
558            .as_str()
559            .unwrap_or_default()
560            .to_string();
561
562        let value = call.arguments["value"]
563            .as_str()
564            .unwrap_or_default()
565            .to_string();
566
567        if format.is_empty() {
568            return Ok(ToolResult::error(&call.id, "Format parameter is required"));
569        }
570        if value.is_empty() {
571            return Ok(ToolResult::error(&call.id, "Value parameter is required"));
572        }
573
574        let result = match format.as_str() {
575            "email" => validate_email(&value),
576            "url" => validate_url(&value),
577            "ipv4" => validate_ipv4(&value),
578            "ipv6" => validate_ipv6(&value),
579            "uuid" => validate_uuid(&value),
580            "phone" => validate_phone(&value),
581            "credit_card" => validate_credit_card(&value),
582            "date" => validate_date(&value),
583            "datetime" => validate_datetime(&value),
584            "hex_color" => validate_hex_color(&value),
585            "semver" => validate_semver(&value),
586            "json" => validate_json(&value),
587            "base64" => validate_base64(&value),
588            "domain" => validate_domain(&value),
589            "mac_address" => validate_mac_address(&value),
590            _ => {
591                return Ok(ToolResult::error(
592                    &call.id,
593                    format!(
594                        "Unknown format '{format}'. Supported: email, url, ipv4, ipv6, uuid, \
595                         phone, credit_card, date, datetime, hex_color, semver, json, base64, \
596                         domain, mac_address"
597                    ),
598                ));
599            }
600        };
601
602        Ok(ToolResult::success(&call.id, result.to_string()))
603    }
604}
605
606// ---------------------------------------------------------------------------
607// Tests
608// ---------------------------------------------------------------------------
609
610#[cfg(test)]
611#[allow(clippy::unwrap_used, clippy::expect_used)]
612mod tests {
613    use super::*;
614
615    fn skill() -> DataValidatorSkill {
616        DataValidatorSkill::new()
617    }
618
619    fn call(format: &str, value: &str) -> ToolCall {
620        ToolCall {
621            id: "t1".to_string(),
622            name: "data_validator".to_string(),
623            arguments: serde_json::json!({"format": format, "value": value}),
624        }
625    }
626
627    fn parse_result(result: &ToolResult) -> serde_json::Value {
628        serde_json::from_str(&result.content).unwrap()
629    }
630
631    // -- Descriptor -----------------------------------------------------------
632
633    #[test]
634    fn test_descriptor() {
635        let s = skill();
636        assert_eq!(s.descriptor().name, "data_validator");
637        assert!(s.descriptor().required_capabilities.is_empty());
638    }
639
640    // -- Email ----------------------------------------------------------------
641
642    #[tokio::test]
643    async fn test_email_valid() {
644        let s = skill();
645        let r = s.execute(call("email", "user@example.com")).await.unwrap();
646        let v = parse_result(&r);
647        assert_eq!(v["valid"], true);
648        assert!(v["details"].as_str().unwrap().contains("example.com"));
649    }
650
651    #[tokio::test]
652    async fn test_email_valid_complex() {
653        let s = skill();
654        let r = s
655            .execute(call("email", "user.name+tag@sub.domain.co.uk"))
656            .await
657            .unwrap();
658        let v = parse_result(&r);
659        assert_eq!(v["valid"], true);
660    }
661
662    #[tokio::test]
663    async fn test_email_invalid() {
664        let s = skill();
665        for invalid in &["notanemail", "@missing.local", "user@", "user@.com"] {
666            let r = s.execute(call("email", invalid)).await.unwrap();
667            let v = parse_result(&r);
668            assert_eq!(v["valid"], false, "Expected invalid for: {invalid}");
669        }
670    }
671
672    // -- URL ------------------------------------------------------------------
673
674    #[tokio::test]
675    async fn test_url_valid() {
676        let s = skill();
677        let r = s
678            .execute(call("url", "https://example.com/path?q=1"))
679            .await
680            .unwrap();
681        let v = parse_result(&r);
682        assert_eq!(v["valid"], true);
683        assert!(v["details"].as_str().unwrap().contains("https"));
684    }
685
686    #[tokio::test]
687    async fn test_url_http() {
688        let s = skill();
689        let r = s.execute(call("url", "http://example.com")).await.unwrap();
690        let v = parse_result(&r);
691        assert_eq!(v["valid"], true);
692    }
693
694    #[tokio::test]
695    async fn test_url_invalid() {
696        let s = skill();
697        for invalid in &["ftp://example.com", "not a url", "://missing.scheme"] {
698            let r = s.execute(call("url", invalid)).await.unwrap();
699            let v = parse_result(&r);
700            assert_eq!(v["valid"], false, "Expected invalid for: {invalid}");
701        }
702    }
703
704    // -- IPv4 -----------------------------------------------------------------
705
706    #[tokio::test]
707    async fn test_ipv4_valid() {
708        let s = skill();
709        for ip in &["192.168.1.1", "0.0.0.0", "255.255.255.255", "10.0.0.1"] {
710            let r = s.execute(call("ipv4", ip)).await.unwrap();
711            let v = parse_result(&r);
712            assert_eq!(v["valid"], true, "Expected valid for: {ip}");
713        }
714    }
715
716    #[tokio::test]
717    async fn test_ipv4_invalid() {
718        let s = skill();
719        for ip in &[
720            "256.0.0.1",
721            "1.2.3",
722            "1.2.3.4.5",
723            "01.02.03.04",
724            "abc.def.ghi.jkl",
725        ] {
726            let r = s.execute(call("ipv4", ip)).await.unwrap();
727            let v = parse_result(&r);
728            assert_eq!(v["valid"], false, "Expected invalid for: {ip}");
729        }
730    }
731
732    // -- IPv6 -----------------------------------------------------------------
733
734    #[tokio::test]
735    async fn test_ipv6_valid() {
736        let s = skill();
737        for ip in &[
738            "::1",
739            "fe80::1",
740            "2001:0db8:85a3::8a2e:0370:7334",
741            "::ffff:192.0.2.1",
742        ] {
743            let r = s.execute(call("ipv6", ip)).await.unwrap();
744            let v = parse_result(&r);
745            assert_eq!(v["valid"], true, "Expected valid for: {ip}");
746        }
747    }
748
749    #[tokio::test]
750    async fn test_ipv6_invalid() {
751        let s = skill();
752        for ip in &["not-ipv6", "12345::abcde", ":::1"] {
753            let r = s.execute(call("ipv6", ip)).await.unwrap();
754            let v = parse_result(&r);
755            assert_eq!(v["valid"], false, "Expected invalid for: {ip}");
756        }
757    }
758
759    // -- UUID -----------------------------------------------------------------
760
761    #[tokio::test]
762    async fn test_uuid_valid() {
763        let s = skill();
764        let r = s
765            .execute(call("uuid", "550e8400-e29b-41d4-a716-446655440000"))
766            .await
767            .unwrap();
768        let v = parse_result(&r);
769        assert_eq!(v["valid"], true);
770        assert!(v["details"].as_str().unwrap().contains("v4"));
771    }
772
773    #[tokio::test]
774    async fn test_uuid_invalid() {
775        let s = skill();
776        for invalid in &[
777            "not-a-uuid",
778            "550e8400-e29b-41d4-a716",
779            "ZZZZZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZZZZZZZZZ",
780        ] {
781            let r = s.execute(call("uuid", invalid)).await.unwrap();
782            let v = parse_result(&r);
783            assert_eq!(v["valid"], false, "Expected invalid for: {invalid}");
784        }
785    }
786
787    // -- Phone ----------------------------------------------------------------
788
789    #[tokio::test]
790    async fn test_phone_valid() {
791        let s = skill();
792        for phone in &[
793            "+1234567890",
794            "1234567890",
795            "+44 20 7946 0958",
796            "(212) 555-1234",
797        ] {
798            let r = s.execute(call("phone", phone)).await.unwrap();
799            let v = parse_result(&r);
800            assert_eq!(v["valid"], true, "Expected valid for: {phone}");
801        }
802    }
803
804    #[tokio::test]
805    async fn test_phone_invalid() {
806        let s = skill();
807        for phone in &["123", "abcdefghij", "+1234567890123456"] {
808            let r = s.execute(call("phone", phone)).await.unwrap();
809            let v = parse_result(&r);
810            assert_eq!(v["valid"], false, "Expected invalid for: {phone}");
811        }
812    }
813
814    // -- Credit card ----------------------------------------------------------
815
816    #[tokio::test]
817    async fn test_credit_card_valid() {
818        let s = skill();
819        // Valid test numbers (Luhn-valid)
820        for card in &["4111111111111111", "5500000000000004", "340000000000009"] {
821            let r = s.execute(call("credit_card", card)).await.unwrap();
822            let v = parse_result(&r);
823            assert_eq!(v["valid"], true, "Expected valid for: {card}");
824        }
825    }
826
827    #[tokio::test]
828    async fn test_credit_card_with_spaces() {
829        let s = skill();
830        let r = s
831            .execute(call("credit_card", "4111 1111 1111 1111"))
832            .await
833            .unwrap();
834        let v = parse_result(&r);
835        assert_eq!(v["valid"], true);
836    }
837
838    #[tokio::test]
839    async fn test_credit_card_invalid_luhn() {
840        let s = skill();
841        let r = s
842            .execute(call("credit_card", "4111111111111112"))
843            .await
844            .unwrap();
845        let v = parse_result(&r);
846        assert_eq!(v["valid"], false);
847        assert!(v["details"].as_str().unwrap().contains("Luhn"));
848    }
849
850    // -- Date -----------------------------------------------------------------
851
852    #[tokio::test]
853    async fn test_date_valid() {
854        let s = skill();
855        for d in &["2024-01-01", "2024-02-29", "2023-12-31"] {
856            let r = s.execute(call("date", d)).await.unwrap();
857            let v = parse_result(&r);
858            assert_eq!(v["valid"], true, "Expected valid for: {d}");
859        }
860    }
861
862    #[tokio::test]
863    async fn test_date_invalid() {
864        let s = skill();
865        for d in &["2024-13-01", "2023-02-29", "2024-00-01", "not-a-date"] {
866            let r = s.execute(call("date", d)).await.unwrap();
867            let v = parse_result(&r);
868            assert_eq!(v["valid"], false, "Expected invalid for: {d}");
869        }
870    }
871
872    // -- Datetime -------------------------------------------------------------
873
874    #[tokio::test]
875    async fn test_datetime_valid() {
876        let s = skill();
877        for dt in &[
878            "2024-01-15T10:30:00Z",
879            "2024-01-15T10:30:00+05:30",
880            "2024-01-15T10:30:00.123Z",
881            "2024-01-15T10:30:00",
882        ] {
883            let r = s.execute(call("datetime", dt)).await.unwrap();
884            let v = parse_result(&r);
885            assert_eq!(v["valid"], true, "Expected valid for: {dt}");
886        }
887    }
888
889    #[tokio::test]
890    async fn test_datetime_invalid() {
891        let s = skill();
892        for dt in &["not-datetime", "2024-13-01T10:00:00Z", "2024-01-15"] {
893            let r = s.execute(call("datetime", dt)).await.unwrap();
894            let v = parse_result(&r);
895            assert_eq!(v["valid"], false, "Expected invalid for: {dt}");
896        }
897    }
898
899    // -- Hex color ------------------------------------------------------------
900
901    #[tokio::test]
902    async fn test_hex_color_valid() {
903        let s = skill();
904        for color in &["#fff", "#FFF", "#aabbcc", "#AABBCC", "#123456"] {
905            let r = s.execute(call("hex_color", color)).await.unwrap();
906            let v = parse_result(&r);
907            assert_eq!(v["valid"], true, "Expected valid for: {color}");
908        }
909    }
910
911    #[tokio::test]
912    async fn test_hex_color_invalid() {
913        let s = skill();
914        for color in &["#gg0000", "ff0000", "#12345", "#1234567"] {
915            let r = s.execute(call("hex_color", color)).await.unwrap();
916            let v = parse_result(&r);
917            assert_eq!(v["valid"], false, "Expected invalid for: {color}");
918        }
919    }
920
921    // -- Semver ---------------------------------------------------------------
922
923    #[tokio::test]
924    async fn test_semver_valid() {
925        let s = skill();
926        for sv in &[
927            "1.0.0",
928            "0.1.0",
929            "1.2.3-alpha",
930            "1.2.3-alpha.1",
931            "1.2.3+build.123",
932        ] {
933            let r = s.execute(call("semver", sv)).await.unwrap();
934            let v = parse_result(&r);
935            assert_eq!(v["valid"], true, "Expected valid for: {sv}");
936        }
937    }
938
939    #[tokio::test]
940    async fn test_semver_invalid() {
941        let s = skill();
942        for sv in &["1.0", "v1.0.0", "01.0.0", "1.0.0.0"] {
943            let r = s.execute(call("semver", sv)).await.unwrap();
944            let v = parse_result(&r);
945            assert_eq!(v["valid"], false, "Expected invalid for: {sv}");
946        }
947    }
948
949    // -- JSON -----------------------------------------------------------------
950
951    #[tokio::test]
952    async fn test_json_valid() {
953        let s = skill();
954        for j in &[
955            r#"{"key": "value"}"#,
956            "[1,2,3]",
957            "\"hello\"",
958            "42",
959            "true",
960            "null",
961        ] {
962            let r = s.execute(call("json", j)).await.unwrap();
963            let v = parse_result(&r);
964            assert_eq!(v["valid"], true, "Expected valid for: {j}");
965        }
966    }
967
968    #[tokio::test]
969    async fn test_json_invalid() {
970        let s = skill();
971        for j in &["{missing: quotes}", "[1,2,", "undefined"] {
972            let r = s.execute(call("json", j)).await.unwrap();
973            let v = parse_result(&r);
974            assert_eq!(v["valid"], false, "Expected invalid for: {j}");
975        }
976    }
977
978    // -- Base64 ---------------------------------------------------------------
979
980    #[tokio::test]
981    async fn test_base64_valid() {
982        let s = skill();
983        for b in &["SGVsbG8=", "SGVsbG8gV29ybGQ=", "dGVzdA=="] {
984            let r = s.execute(call("base64", b)).await.unwrap();
985            let v = parse_result(&r);
986            assert_eq!(v["valid"], true, "Expected valid for: {b}");
987        }
988    }
989
990    #[tokio::test]
991    async fn test_base64_invalid() {
992        let s = skill();
993        for b in &["SGVsbG8!", "not base64 at all!!!"] {
994            let r = s.execute(call("base64", b)).await.unwrap();
995            let v = parse_result(&r);
996            assert_eq!(v["valid"], false, "Expected invalid for: {b}");
997        }
998    }
999
1000    // -- Domain ---------------------------------------------------------------
1001
1002    #[tokio::test]
1003    async fn test_domain_valid() {
1004        let s = skill();
1005        for d in &["example.com", "sub.domain.co.uk", "a-b.example.org"] {
1006            let r = s.execute(call("domain", d)).await.unwrap();
1007            let v = parse_result(&r);
1008            assert_eq!(v["valid"], true, "Expected valid for: {d}");
1009        }
1010    }
1011
1012    #[tokio::test]
1013    async fn test_domain_invalid() {
1014        let s = skill();
1015        for d in &[
1016            "-invalid.com",
1017            "no_underscores.com",
1018            ".leading-dot.com",
1019            "a",
1020        ] {
1021            let r = s.execute(call("domain", d)).await.unwrap();
1022            let v = parse_result(&r);
1023            assert_eq!(v["valid"], false, "Expected invalid for: {d}");
1024        }
1025    }
1026
1027    // -- MAC address ----------------------------------------------------------
1028
1029    #[tokio::test]
1030    async fn test_mac_valid() {
1031        let s = skill();
1032        for mac in &[
1033            "00:1A:2B:3C:4D:5E",
1034            "aa:bb:cc:dd:ee:ff",
1035            "AA-BB-CC-DD-EE-FF",
1036        ] {
1037            let r = s.execute(call("mac_address", mac)).await.unwrap();
1038            let v = parse_result(&r);
1039            assert_eq!(v["valid"], true, "Expected valid for: {mac}");
1040        }
1041    }
1042
1043    #[tokio::test]
1044    async fn test_mac_invalid() {
1045        let s = skill();
1046        for mac in &["00:1A:2B:3C:4D", "GG:HH:II:JJ:KK:LL", "001A2B3C4D5E"] {
1047            let r = s.execute(call("mac_address", mac)).await.unwrap();
1048            let v = parse_result(&r);
1049            assert_eq!(v["valid"], false, "Expected invalid for: {mac}");
1050        }
1051    }
1052
1053    // -- Error handling -------------------------------------------------------
1054
1055    #[tokio::test]
1056    async fn test_unknown_format() {
1057        let s = skill();
1058        let r = s.execute(call("unknown_format", "test")).await.unwrap();
1059        assert!(r.is_error);
1060        assert!(r.content.contains("Unknown format"));
1061    }
1062
1063    #[tokio::test]
1064    async fn test_empty_format() {
1065        let s = skill();
1066        let c = ToolCall {
1067            id: "t1".to_string(),
1068            name: "data_validator".to_string(),
1069            arguments: serde_json::json!({"format": "", "value": "test"}),
1070        };
1071        let r = s.execute(c).await.unwrap();
1072        assert!(r.is_error);
1073        assert!(r.content.contains("Format parameter is required"));
1074    }
1075
1076    #[tokio::test]
1077    async fn test_empty_value() {
1078        let s = skill();
1079        let c = ToolCall {
1080            id: "t1".to_string(),
1081            name: "data_validator".to_string(),
1082            arguments: serde_json::json!({"format": "email", "value": ""}),
1083        };
1084        let r = s.execute(c).await.unwrap();
1085        assert!(r.is_error);
1086        assert!(r.content.contains("Value parameter is required"));
1087    }
1088
1089    // -- Default trait --------------------------------------------------------
1090
1091    #[test]
1092    fn test_default() {
1093        let s = DataValidatorSkill::default();
1094        assert_eq!(s.descriptor().name, "data_validator");
1095    }
1096}