Skip to main content

swf_core/models/
duration.rs

1use crate::models::expression::is_strict_expr;
2use serde::{de, Deserialize, Serialize};
3use std::fmt;
4
5/// Represents a duration
6#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
7pub struct Duration {
8    /// Gets/sets the number of days, if any
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub days: Option<u64>,
11
12    /// Gets/sets the number of hours, if any
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub hours: Option<u64>,
15
16    /// Gets/sets the number of minutes, if any
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub minutes: Option<u64>,
19
20    /// Gets/sets the number of seconds, if any
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub seconds: Option<u64>,
23
24    /// Gets/sets the number of milliseconds, if any
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub milliseconds: Option<u64>,
27}
28
29impl<'de> de::Deserialize<'de> for Duration {
30    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
31    where
32        D: serde::Deserializer<'de>,
33    {
34        struct DurationVisitor;
35
36        impl<'de> de::Visitor<'de> for DurationVisitor {
37            type Value = Duration;
38
39            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
40                formatter.write_str("a duration object with at least one property")
41            }
42
43            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
44            where
45                A: de::MapAccess<'de>,
46            {
47                let mut days: Option<u64> = None;
48                let mut hours: Option<u64> = None;
49                let mut minutes: Option<u64> = None;
50                let mut seconds: Option<u64> = None;
51                let mut milliseconds: Option<u64> = None;
52                let mut has_key = false;
53
54                while let Some(key) = map.next_key::<String>()? {
55                    has_key = true;
56                    match key.as_str() {
57                        "days" => {
58                            days = Some(map.next_value()?);
59                        }
60                        "hours" => {
61                            hours = Some(map.next_value()?);
62                        }
63                        "minutes" => {
64                            minutes = Some(map.next_value()?);
65                        }
66                        "seconds" => {
67                            seconds = Some(map.next_value()?);
68                        }
69                        "milliseconds" => {
70                            milliseconds = Some(map.next_value()?);
71                        }
72                        other => {
73                            return Err(de::Error::custom(format!(
74                                "unexpected key '{}' in duration object",
75                                other
76                            )));
77                        }
78                    }
79                }
80
81                if !has_key {
82                    return Err(de::Error::custom(
83                        "duration object must include at least one property",
84                    ));
85                }
86
87                Ok(Duration {
88                    days,
89                    hours,
90                    minutes,
91                    seconds,
92                    milliseconds,
93                })
94            }
95        }
96
97        deserializer.deserialize_map(DurationVisitor)
98    }
99}
100macro_rules! from_unit {
101    ($name:ident, $field:ident) => {
102        pub fn $name(v: u64) -> Self {
103            Self {
104                $field: Some(v),
105                ..Self::default()
106            }
107        }
108    };
109}
110
111macro_rules! total_as {
112    ($name:ident, $divisor:expr) => {
113        pub fn $name(&self) -> f64 {
114            self.total_milliseconds() as f64 / $divisor
115        }
116    };
117}
118
119impl Duration {
120    from_unit!(from_days, days);
121    from_unit!(from_hours, hours);
122    from_unit!(from_minutes, minutes);
123    from_unit!(from_seconds, seconds);
124    from_unit!(from_milliseconds, milliseconds);
125
126    total_as!(total_days, 24.0 * 60.0 * 60.0 * 1000.0);
127    total_as!(total_hours, 60.0 * 60.0 * 1000.0);
128    total_as!(total_minutes, 60.0 * 1000.0);
129    total_as!(total_seconds, 1000.0);
130
131    /// Gets the the duration's total amount of milliseconds
132    pub fn total_milliseconds(&self) -> u64 {
133        let total: u128 = (self.days.unwrap_or(0) as u128) * 86_400_000
134            + (self.hours.unwrap_or(0) as u128) * 3_600_000
135            + (self.minutes.unwrap_or(0) as u128) * 60_000
136            + (self.seconds.unwrap_or(0) as u128) * 1_000
137            + self.milliseconds.unwrap_or(0) as u128;
138        total.try_into().unwrap_or(u64::MAX)
139    }
140}
141impl fmt::Display for Duration {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        let mut parts = Vec::new();
144        if let Some(days) = self.days {
145            parts.push(format!("{} days", days));
146        }
147        if let Some(hours) = self.hours {
148            parts.push(format!("{} hours", hours));
149        }
150        if let Some(minutes) = self.minutes {
151            parts.push(format!("{} minutes", minutes));
152        }
153        if let Some(seconds) = self.seconds {
154            parts.push(format!("{} seconds", seconds));
155        }
156        if let Some(milliseconds) = self.milliseconds {
157            parts.push(format!("{} milliseconds", milliseconds));
158        }
159        write!(f, "{}", parts.join(" "))
160    }
161}
162
163/// Represents a value that can be either a Duration or an ISO 8601 duration expression
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum OneOfDurationOrIso8601Expression {
166    /// Variant holding a duration
167    Duration(Duration),
168    /// Variant holding an ISO 8601 duration expression
169    Iso8601Expression(String),
170}
171
172impl<'de> de::Deserialize<'de> for OneOfDurationOrIso8601Expression {
173    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
174    where
175        D: serde::Deserializer<'de>,
176    {
177        // Use a helper that can handle both object and string forms
178        struct OneOfDurationVisitor;
179
180        impl<'de> de::Visitor<'de> for OneOfDurationVisitor {
181            type Value = OneOfDurationOrIso8601Expression;
182
183            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
184                formatter.write_str("a duration object or an ISO 8601 duration string")
185            }
186
187            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
188            where
189                A: de::MapAccess<'de>,
190            {
191                // Deserialize as Duration (inline object)
192                let duration = Duration::deserialize(de::value::MapAccessDeserializer::new(map))?;
193                Ok(OneOfDurationOrIso8601Expression::Duration(duration))
194            }
195
196            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
197            where
198                E: de::Error,
199            {
200                // Runtime expressions (e.g., "${ .delay }") are accepted as-is
201                if is_strict_expr(v) {
202                    return Ok(OneOfDurationOrIso8601Expression::Iso8601Expression(
203                        v.to_string(),
204                    ));
205                }
206                // Validate ISO 8601 expression (matches Go SDK's Duration.UnmarshalJSON behavior)
207                if !is_iso8601_duration_valid(v) {
208                    return Err(de::Error::custom(format!(
209                        "invalid ISO 8601 duration expression: '{}'",
210                        v
211                    )));
212                }
213                Ok(OneOfDurationOrIso8601Expression::Iso8601Expression(
214                    v.to_string(),
215                ))
216            }
217        }
218
219        deserializer.deserialize_any(OneOfDurationVisitor)
220    }
221}
222
223impl serde::Serialize for OneOfDurationOrIso8601Expression {
224    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
225    where
226        S: serde::Serializer,
227    {
228        match self {
229            OneOfDurationOrIso8601Expression::Duration(d) => d.serialize(serializer),
230            OneOfDurationOrIso8601Expression::Iso8601Expression(s) => serializer.serialize_str(s),
231        }
232    }
233}
234
235impl Default for OneOfDurationOrIso8601Expression {
236    fn default() -> Self {
237        // Choose a default variant
238        OneOfDurationOrIso8601Expression::Duration(Duration::default())
239    }
240}
241impl fmt::Display for OneOfDurationOrIso8601Expression {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        match self {
244            OneOfDurationOrIso8601Expression::Duration(duration) => write!(f, "{}", duration),
245            OneOfDurationOrIso8601Expression::Iso8601Expression(expr) => write!(f, "{}", expr),
246        }
247    }
248}
249
250impl From<Duration> for OneOfDurationOrIso8601Expression {
251    fn from(duration: Duration) -> Self {
252        OneOfDurationOrIso8601Expression::Duration(duration)
253    }
254}
255
256impl OneOfDurationOrIso8601Expression {
257    /// Gets the total milliseconds for inline Duration variant.
258    /// Returns 0 for ISO8601 expression variant (needs runtime parsing).
259    pub fn total_milliseconds(&self) -> u64 {
260        match self {
261            OneOfDurationOrIso8601Expression::Duration(d) => d.total_milliseconds(),
262            OneOfDurationOrIso8601Expression::Iso8601Expression(_) => 0,
263        }
264    }
265
266    /// Returns true if this is an inline Duration variant
267    pub fn is_duration(&self) -> bool {
268        matches!(self, OneOfDurationOrIso8601Expression::Duration(_))
269    }
270
271    /// Returns true if this is an ISO8601 expression variant
272    pub fn is_iso8601(&self) -> bool {
273        matches!(self, OneOfDurationOrIso8601Expression::Iso8601Expression(_))
274    }
275
276    /// Gets the ISO8601 expression string, if this is an expression variant
277    pub fn as_iso8601(&self) -> Option<&str> {
278        match self {
279            OneOfDurationOrIso8601Expression::Iso8601Expression(s) => Some(s),
280            _ => None,
281        }
282    }
283}
284
285/// Validates whether an ISO 8601 duration string is supported by the Serverless Workflow spec.
286/// Rejects: years (Y), weeks (W), months (M in date part), fractional days, bare P/PT,
287/// and non-ISO formats.
288/// Matches Go SDK's isISO8601DurationValid regex validator behavior.
289pub fn is_iso8601_duration_valid(s: &str) -> bool {
290    if !s.starts_with('P') {
291        return false;
292    }
293    let rest = &s[1..];
294    if rest.is_empty() {
295        return false; // bare "P" is invalid
296    }
297
298    let (date_part, time_part) = if let Some(t_idx) = rest.find('T') {
299        let date = &rest[..t_idx];
300        let time = &rest[t_idx + 1..];
301        if time.is_empty() {
302            return false; // bare "PT" is invalid
303        }
304        (date, Some(time))
305    } else {
306        (rest, None)
307    };
308
309    // Date part: only integer days supported (no Y, W, M for months, fractional days)
310    if !date_part.is_empty() {
311        let days_str = date_part.trim_end_matches('D');
312        if days_str.is_empty() && date_part.ends_with('D') {
313            return false; // bare D
314        }
315        if !days_str.is_empty() {
316            // Must be integer (no fractional days like P1.5D)
317            if days_str.parse::<u64>().is_err() {
318                return false;
319            }
320        }
321        // Reject any Y, W, or M in date part
322        if date_part.contains('Y') || date_part.contains('W') || date_part.contains('M') {
323            return false;
324        }
325    }
326
327    // Time part validation: parse component by component and ensure full consumption
328    if let Some(time) = time_part {
329        let remaining = parse_time_components(time);
330        if remaining.is_none() {
331            return false;
332        }
333        // If there's unparsed trailing content, it's invalid (e.g., "5MS7" leaves "7")
334        if let Some(remaining) = remaining {
335            if !remaining.is_empty() {
336                return false;
337            }
338        }
339    }
340
341    true
342}
343
344/// Parses time components (H, M, S, MS) and returns the remaining unparsed string.
345/// Returns None if the format is invalid.
346fn parse_time_components(mut s: &str) -> Option<&str> {
347    // Order matters: try MS (milliseconds) before M (minutes) and S (seconds)
348    // We need to handle the custom "MS" suffix used by the Serverless Workflow spec
349    while !s.is_empty() {
350        // Try to parse a number followed by a unit
351        let (num_str, rest) = split_number_prefix(s)?;
352        if num_str.is_empty() {
353            return None; // no number found
354        }
355        // Validate the number (allow integer or decimal for seconds)
356        if num_str.parse::<f64>().is_err() {
357            return None;
358        }
359        // Check for unit suffix: MS (milliseconds) must be checked before M and S
360        if let Some(rest_after_ms) = rest.strip_prefix("MS") {
361            s = rest_after_ms;
362        } else if let Some(rest_after_h) = rest.strip_prefix('H') {
363            s = rest_after_h;
364        } else if let Some(rest_after_m) = rest.strip_prefix('M') {
365            s = rest_after_m;
366        } else if let Some(rest_after_s) = rest.strip_prefix('S') {
367            s = rest_after_s;
368        } else {
369            // No recognized unit suffix — return remaining for caller to check
370            return Some(s);
371        }
372    }
373    Some(s) // empty string = fully consumed
374}
375
376/// Splits a string into the leading numeric portion and the rest.
377fn split_number_prefix(s: &str) -> Option<(&str, &str)> {
378    let mut i = 0;
379    let bytes = s.as_bytes();
380    // Allow optional leading minus (though durations shouldn't have it)
381    if i < bytes.len() && bytes[i] == b'-' {
382        i += 1;
383    }
384    // Integer part
385    while i < bytes.len() && bytes[i].is_ascii_digit() {
386        i += 1;
387    }
388    // Optional decimal part
389    if i < bytes.len() && bytes[i] == b'.' {
390        i += 1;
391        while i < bytes.len() && bytes[i].is_ascii_digit() {
392            i += 1;
393        }
394    }
395    if i == 0 {
396        return None;
397    }
398    Some((&s[..i], &s[i..]))
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_duration_from_days() {
407        let d = Duration::from_days(1);
408        assert_eq!(d.days, Some(1));
409        assert_eq!(d.total_milliseconds(), 86400000);
410    }
411
412    #[test]
413    fn test_duration_from_hours() {
414        let d = Duration::from_hours(2);
415        assert_eq!(d.hours, Some(2));
416        assert_eq!(d.total_milliseconds(), 7200000);
417    }
418
419    #[test]
420    fn test_duration_from_minutes() {
421        let d = Duration::from_minutes(5);
422        assert_eq!(d.minutes, Some(5));
423        assert_eq!(d.total_milliseconds(), 300000);
424    }
425
426    #[test]
427    fn test_duration_from_seconds() {
428        let d = Duration::from_seconds(30);
429        assert_eq!(d.seconds, Some(30));
430        assert_eq!(d.total_milliseconds(), 30000);
431    }
432
433    #[test]
434    fn test_duration_from_milliseconds() {
435        let d = Duration::from_milliseconds(500);
436        assert_eq!(d.milliseconds, Some(500));
437        assert_eq!(d.total_milliseconds(), 500);
438    }
439
440    #[test]
441    fn test_duration_composite() {
442        let d = Duration {
443            days: Some(1),
444            hours: Some(2),
445            minutes: Some(30),
446            seconds: Some(45),
447            milliseconds: Some(500),
448        };
449        let expected = 86400000 + 7200000 + 1800000 + 45000 + 500;
450        assert_eq!(d.total_milliseconds(), expected);
451    }
452
453    #[test]
454    fn test_duration_total_conversions() {
455        let d = Duration::from_minutes(90);
456        assert_eq!(d.total_hours(), 1.5);
457        assert_eq!(d.total_minutes(), 90.0);
458        assert_eq!(d.total_seconds(), 5400.0);
459    }
460
461    #[test]
462    fn test_duration_serialize() {
463        let d = Duration::from_seconds(30);
464        let json = serde_json::to_string(&d).unwrap();
465        assert_eq!(json, r#"{"seconds":30}"#);
466    }
467
468    #[test]
469    fn test_duration_deserialize() {
470        let json = r#"{"minutes": 5, "seconds": 30}"#;
471        let d: Duration = serde_json::from_str(json).unwrap();
472        assert_eq!(d.minutes, Some(5));
473        assert_eq!(d.seconds, Some(30));
474    }
475
476    #[test]
477    fn test_duration_empty_object_rejected() {
478        // Matches Go SDK: Duration.UnmarshalJSON rejects empty objects
479        let json = r#"{}"#;
480        let result: Result<Duration, _> = serde_json::from_str(json);
481        assert!(result.is_err(), "empty duration object should be rejected");
482        let err = result.unwrap_err().to_string();
483        assert!(
484            err.contains("at least one property"),
485            "expected 'at least one property' error, got: {}",
486            err
487        );
488    }
489
490    #[test]
491    fn test_duration_unknown_key_rejected() {
492        // Matches Go SDK: Duration.UnmarshalJSON rejects unknown keys
493        let json = r#"{"after": "PT1S"}"#;
494        let result: Result<Duration, _> = serde_json::from_str(json);
495        assert!(
496            result.is_err(),
497            "unknown key in duration object should be rejected"
498        );
499        let err = result.unwrap_err().to_string();
500        assert!(
501            err.contains("unexpected key"),
502            "expected 'unexpected key' error, got: {}",
503            err
504        );
505    }
506
507    #[test]
508    fn test_duration_unknown_key_mixed_rejected() {
509        // Unknown key mixed with valid keys should still be rejected
510        let json = r#"{"seconds": 30, "duration": "PT1S"}"#;
511        let result: Result<Duration, _> = serde_json::from_str(json);
512        assert!(
513            result.is_err(),
514            "unknown key mixed with valid keys should be rejected"
515        );
516    }
517
518    #[test]
519    fn test_duration_default() {
520        let d = Duration::default();
521        assert_eq!(d.total_milliseconds(), 0);
522    }
523
524    #[test]
525    fn test_oneof_duration_serialize_struct() {
526        let oneof = OneOfDurationOrIso8601Expression::Duration(Duration::from_seconds(30));
527        let json = serde_json::to_string(&oneof).unwrap();
528        assert_eq!(json, r#"{"seconds":30}"#);
529    }
530
531    #[test]
532    fn test_oneof_duration_serialize_iso8601() {
533        let oneof = OneOfDurationOrIso8601Expression::Iso8601Expression("PT5M".to_string());
534        let json = serde_json::to_string(&oneof).unwrap();
535        assert_eq!(json, r#""PT5M""#);
536    }
537
538    #[test]
539    fn test_oneof_duration_deserialize_struct() {
540        let json = r#"{"seconds": 30}"#;
541        let oneof: OneOfDurationOrIso8601Expression = serde_json::from_str(json).unwrap();
542        match oneof {
543            OneOfDurationOrIso8601Expression::Duration(d) => {
544                assert_eq!(d.seconds, Some(30));
545            }
546            _ => panic!("Expected Duration variant"),
547        }
548    }
549
550    #[test]
551    fn test_oneof_duration_deserialize_iso8601() {
552        let json = r#""PT5M""#;
553        let oneof: OneOfDurationOrIso8601Expression = serde_json::from_str(json).unwrap();
554        match oneof {
555            OneOfDurationOrIso8601Expression::Iso8601Expression(s) => {
556                assert_eq!(s, "PT5M");
557            }
558            _ => panic!("Expected Iso8601Expression variant"),
559        }
560    }
561
562    #[test]
563    fn test_duration_display() {
564        let d = Duration {
565            hours: Some(2),
566            minutes: Some(30),
567            ..Default::default()
568        };
569        let display = format!("{}", d);
570        assert!(display.contains("2 hours"));
571        assert!(display.contains("30 minutes"));
572    }
573
574    // Additional tests matching Go SDK's duration_test.go and validator_test.go
575
576    #[test]
577    fn test_oneof_iso8601_valid_patterns() {
578        // Valid ISO8601 patterns accepted by Go SDK
579        let valid_cases = vec![
580            ("\"P1D\"", "P1D"),
581            ("\"P1DT12H30M\"", "P1DT12H30M"),
582            ("\"PT1H\"", "PT1H"),
583            ("\"PT250MS\"", "PT250MS"),
584            ("\"P3DT4H5M6S250MS\"", "P3DT4H5M6S250MS"),
585        ];
586        for (json, expected) in valid_cases {
587            let oneof: OneOfDurationOrIso8601Expression = serde_json::from_str(json).unwrap();
588            match &oneof {
589                OneOfDurationOrIso8601Expression::Iso8601Expression(s) => {
590                    assert_eq!(s, expected, "expected ISO expression {}", expected);
591                }
592                _ => panic!("Expected Iso8601Expression variant for {}", expected),
593            }
594        }
595    }
596
597    #[test]
598    fn test_oneof_iso8601_rejected_patterns() {
599        // Patterns rejected by Go SDK validator — now rejected at deserialization time
600        // (matches Go SDK's Duration.UnmarshalJSON which validates at parse time)
601        let rejected = vec![
602            "\"P2Y\"",     // years not supported
603            "\"P1Y2M3D\"", // months not supported in date part
604            "\"P1W\"",     // weeks not supported
605            "\"1Y\"",      // missing P prefix
606        ];
607        for json in rejected {
608            // These should now fail at deserialization (matching Go SDK behavior)
609            let result: Result<OneOfDurationOrIso8601Expression, _> = serde_json::from_str(json);
610            assert!(result.is_err(), "expected {} to fail deserialization", json);
611        }
612    }
613
614    #[test]
615    fn test_duration_composite_with_all_fields() {
616        let d = Duration {
617            days: Some(3),
618            hours: Some(4),
619            minutes: Some(5),
620            seconds: Some(6),
621            milliseconds: Some(250),
622        };
623        let expected = 3 * 86400000 + 4 * 3600000 + 5 * 60000 + 6 * 1000 + 250;
624        assert_eq!(d.total_milliseconds(), expected);
625    }
626
627    // ISO8601 validation tests matching Go SDK's validator_test.go
628
629    #[test]
630    fn test_iso8601_duration_valid_patterns() {
631        assert!(is_iso8601_duration_valid("P1D"), "P1D should be valid");
632        assert!(
633            is_iso8601_duration_valid("P1DT12H30M"),
634            "P1DT12H30M should be valid"
635        );
636        assert!(is_iso8601_duration_valid("PT1H"), "PT1H should be valid");
637        assert!(
638            is_iso8601_duration_valid("PT250MS"),
639            "PT250MS should be valid"
640        );
641        assert!(
642            is_iso8601_duration_valid("P3DT4H5M6S250MS"),
643            "P3DT4H5M6S250MS should be valid"
644        );
645        assert!(is_iso8601_duration_valid("PT30S"), "PT30S should be valid");
646        assert!(
647            is_iso8601_duration_valid("PT0.1S"),
648            "PT0.1S should be valid"
649        );
650        assert!(
651            is_iso8601_duration_valid("P1DT2H30M"),
652            "P1DT2H30M should be valid"
653        );
654    }
655
656    #[test]
657    fn test_iso8601_duration_invalid_patterns() {
658        // Matches Go SDK's validator_test.go rejected patterns
659        assert!(!is_iso8601_duration_valid("P2Y"), "years not supported");
660        assert!(
661            !is_iso8601_duration_valid("P1Y2M3D"),
662            "months not supported in date part"
663        );
664        assert!(!is_iso8601_duration_valid("P1W"), "weeks not supported");
665        assert!(
666            !is_iso8601_duration_valid("P1Y2M3D4H"),
667            "years+months not supported"
668        );
669        assert!(
670            !is_iso8601_duration_valid("P1Y2M3D4H5M6S"),
671            "years+months not supported"
672        );
673        assert!(!is_iso8601_duration_valid("P"), "bare P is invalid");
674        assert!(!is_iso8601_duration_valid("P1DT"), "bare PT is invalid");
675        assert!(!is_iso8601_duration_valid("1Y"), "missing P prefix");
676        assert!(!is_iso8601_duration_valid(""), "empty string is invalid");
677        assert!(
678            !is_iso8601_duration_valid("P1.5D"),
679            "fractional days not supported"
680        );
681        assert!(
682            !is_iso8601_duration_valid("P1M"),
683            "months (M in date part) not supported"
684        );
685        // Additional from Go SDK validator_test.go
686        assert!(
687            !is_iso8601_duration_valid("P1DT2H3M4S5MS7"),
688            "trailing garbage after MS not valid"
689        );
690    }
691
692    // Additional duration validation tests matching Go SDK's duration_test.go
693
694    #[test]
695    fn test_iso8601_non_iso_rejected_patterns() {
696        // Matches Go SDK: DurationToTime rejects non-ISO formats like "10s", "150ms"
697        assert!(
698            !is_iso8601_duration_valid("10s"),
699            "non-ISO '10s' should be rejected"
700        );
701        assert!(
702            !is_iso8601_duration_valid("150ms"),
703            "non-ISO '150ms' should be rejected"
704        );
705        assert!(
706            !is_iso8601_duration_valid("1Y"),
707            "non-ISO '1Y' should be rejected"
708        );
709        assert!(
710            !is_iso8601_duration_valid("PT"),
711            "bare 'PT' should be rejected"
712        );
713    }
714
715    #[test]
716    fn test_iso8601_p1dt1h_valid() {
717        // Matches Go SDK: DurationToTime with "P1DT1H" → 25 hours
718        assert!(
719            is_iso8601_duration_valid("P1DT1H"),
720            "P1DT1H should be valid"
721        );
722    }
723
724    #[test]
725    fn test_iso8601_pt1s250ms_valid() {
726        // Matches Go SDK: DurationToTime with "PT1S250MS" → 1250ms
727        assert!(
728            is_iso8601_duration_valid("PT1S250MS"),
729            "PT1S250MS should be valid"
730        );
731    }
732
733    #[test]
734    fn test_iso8601_rejected_year() {
735        // Matches Go SDK: DurationToTime_YearExpressionRejected
736        assert!(
737            !is_iso8601_duration_valid("P1Y"),
738            "P1Y should be rejected (years)"
739        );
740    }
741
742    #[test]
743    fn test_iso8601_rejected_week() {
744        // Matches Go SDK: DurationToTime_WeekExpressionRejected
745        assert!(
746            !is_iso8601_duration_valid("P1W"),
747            "P1W should be rejected (weeks)"
748        );
749    }
750
751    #[test]
752    fn test_iso8601_rejected_fractional_day() {
753        // Matches Go SDK: DurationToTime_FractionalDayExpressionRejected
754        assert!(
755            !is_iso8601_duration_valid("P1.5D"),
756            "P1.5D should be rejected (fractional days)"
757        );
758    }
759
760    #[test]
761    fn test_iso8601_rejected_month() {
762        // Matches Go SDK: DurationToTime_UnsupportedMonthExpressionRejected
763        assert!(
764            !is_iso8601_duration_valid("P1M"),
765            "P1M should be rejected (months)"
766        );
767    }
768
769    #[test]
770    fn test_iso8601_rejected_bare_pt() {
771        // Matches Go SDK: DurationToTime_InvalidBarePTExpressionRejected
772        assert!(
773            !is_iso8601_duration_valid("PT"),
774            "bare PT should be rejected"
775        );
776    }
777
778    #[test]
779    fn test_iso8601_rejected_invalid_expression() {
780        // Matches Go SDK: DurationToTime_InvalidExpression
781        assert!(
782            !is_iso8601_duration_valid("1Y"),
783            "1Y without P prefix should be rejected"
784        );
785    }
786
787    #[test]
788    fn test_oneof_duration_roundtrip_struct() {
789        let duration = OneOfDurationOrIso8601Expression::Duration(Duration {
790            days: Some(1),
791            hours: Some(2),
792            minutes: Some(30),
793            ..Default::default()
794        });
795        let serialized = serde_json::to_string(&duration).unwrap();
796        let deserialized: OneOfDurationOrIso8601Expression =
797            serde_json::from_str(&serialized).unwrap();
798        assert_eq!(duration, deserialized);
799    }
800
801    #[test]
802    fn test_oneof_duration_roundtrip_iso8601() {
803        let duration =
804            OneOfDurationOrIso8601Expression::Iso8601Expression("P3DT4H5M6S250MS".to_string());
805        let serialized = serde_json::to_string(&duration).unwrap();
806        let deserialized: OneOfDurationOrIso8601Expression =
807            serde_json::from_str(&serialized).unwrap();
808        assert_eq!(duration, deserialized);
809    }
810}