|
| 1 | +//! XSD Duration format |
| 2 | +
|
| 3 | +use std::ops::Div; |
| 4 | +use std::str::FromStr; |
| 5 | + |
| 6 | +use instant_xml::{FromXml, ToXml}; |
| 7 | + |
| 8 | +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] |
| 9 | +pub struct XsdDuration { |
| 10 | + months: i64, |
| 11 | + seconds: f64, |
| 12 | +} |
| 13 | + |
| 14 | +impl XsdDuration { |
| 15 | + pub fn new(months: i64, seconds: f64) -> Result<Self, InvalidMinutesOrSeconds> { |
| 16 | + if months < 0 && seconds > 0.0 || months > 0 && seconds < 0.0 { |
| 17 | + return Err(InvalidMinutesOrSeconds); |
| 18 | + } |
| 19 | + Ok(Self { months, seconds }) |
| 20 | + } |
| 21 | + |
| 22 | + fn is_zero(&self) -> bool { |
| 23 | + self.months == 0 && self.seconds == 0.0 |
| 24 | + } |
| 25 | +} |
| 26 | + |
| 27 | +impl FromStr for XsdDuration { |
| 28 | + type Err = ParseError; |
| 29 | + |
| 30 | + /// Parses an XSD duration string into [`XsdDuration`]. |
| 31 | + /// |
| 32 | + /// Algorithm based on https://www.w3.org/TR/xmlschema11-2/#f-durationMap |
| 33 | + /// |
| 34 | + /// DUR consists of possibly a leading '-', followed by 'P' and then an instance Y of duYearMonthFrag and/or an instance D of duDayTimeFrag: |
| 35 | + /// Return a duration whose: |
| 36 | + /// * months value is 12 * Y + M |
| 37 | + /// * seconds value is 86400 * D + (3600 * H + 60 * M + S) |
| 38 | + /// |
| 39 | + /// where Y, M, D, H, M, S are the values parsed from the string. |
| 40 | + /// |
| 41 | + /// All values default to 0 if not present. |
| 42 | + fn from_str(s: &str) -> Result<Self, Self::Err> { |
| 43 | + // DUR consists of possibly a leading '-', followed by 'P' and then an instance Y of duYearMonthFrag and/or an instance D of duDayTimeFrag: |
| 44 | + let sgn = if s.starts_with('-') { -1 } else { 1 }; |
| 45 | + let s = s.trim_start_matches('-'); |
| 46 | + if !s.starts_with('P') { |
| 47 | + return Err(ParseError); |
| 48 | + } |
| 49 | + let s = &s[1..]; |
| 50 | + |
| 51 | + // duYearMonthFrag |
| 52 | + let (s, months) = { |
| 53 | + let mut y = 0; |
| 54 | + let s = match s.split_once("Y") { |
| 55 | + Some((l, r)) => { |
| 56 | + y = l.parse::<u32>().map_err(|_| ParseError)?; |
| 57 | + r |
| 58 | + } |
| 59 | + None => s, |
| 60 | + }; |
| 61 | + let mut m = 0; |
| 62 | + let s = match s.split_once("M") { |
| 63 | + // skip months if the M was part of duDayTimeFrag |
| 64 | + Some((l, r)) if !l.contains('T') => { |
| 65 | + m = l.parse::<u32>().map_err(|_| ParseError)?; |
| 66 | + r |
| 67 | + } |
| 68 | + _ => s, |
| 69 | + }; |
| 70 | + |
| 71 | + (s, 12 * (y as i64) + (m as i64) * sgn) |
| 72 | + }; |
| 73 | + |
| 74 | + // duDayTimeFrag |
| 75 | + let seconds = { |
| 76 | + let mut d = 0; |
| 77 | + |
| 78 | + let s = match s.split_once('D') { |
| 79 | + Some((l, r)) => { |
| 80 | + d = l.parse::<u32>().map_err(|_| ParseError)?; |
| 81 | + r |
| 82 | + } |
| 83 | + None => s, |
| 84 | + }; |
| 85 | + if !s.starts_with("T") { |
| 86 | + return Ok(XsdDuration { |
| 87 | + months, |
| 88 | + seconds: (86400 * d) as f64, |
| 89 | + }); |
| 90 | + } |
| 91 | + let s = &s[1..]; |
| 92 | + let t = { |
| 93 | + let mut h = 0; |
| 94 | + let mut m = 0; |
| 95 | + let mut ss = 0.0; |
| 96 | + let s = match s.split_once('H') { |
| 97 | + Some((l, r)) => { |
| 98 | + h = l.parse::<u32>().map_err(|_| ParseError)?; |
| 99 | + r |
| 100 | + } |
| 101 | + None => s, |
| 102 | + }; |
| 103 | + let s = match s.split_once('M') { |
| 104 | + Some((l, r)) => { |
| 105 | + m = l.parse::<u32>().map_err(|_| ParseError)?; |
| 106 | + r |
| 107 | + } |
| 108 | + None => s, |
| 109 | + }; |
| 110 | + if let Some((l, _r)) = s.split_once('S') { |
| 111 | + ss = l.parse::<f64>().map_err(|_| ParseError)?; |
| 112 | + } |
| 113 | + |
| 114 | + (3600 * h) as f64 + (60 * m) as f64 + ss |
| 115 | + }; |
| 116 | + |
| 117 | + (86400 * d) as f64 + t |
| 118 | + }; |
| 119 | + |
| 120 | + Ok(XsdDuration { months, seconds }) |
| 121 | + } |
| 122 | +} |
| 123 | + |
| 124 | +impl<'xml> FromXml<'xml> for XsdDuration { |
| 125 | + fn matches(id: instant_xml::Id<'_>, field: Option<instant_xml::Id<'_>>) -> bool { |
| 126 | + match field { |
| 127 | + Some(field) => id == field, |
| 128 | + None => false, |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + fn deserialize<'cx>( |
| 133 | + into: &mut Self::Accumulator, |
| 134 | + field: &'static str, |
| 135 | + deserializer: &mut instant_xml::Deserializer<'cx, 'xml>, |
| 136 | + ) -> Result<(), instant_xml::Error> { |
| 137 | + if into.is_some() { |
| 138 | + return Err(instant_xml::Error::DuplicateValue(field)); |
| 139 | + } |
| 140 | + |
| 141 | + match deserializer.take_str()? { |
| 142 | + Some(value) => { |
| 143 | + let duration = value.parse().map_err(|_| { |
| 144 | + instant_xml::Error::Other(format!("failed to parse xsd duration: {}", value)) |
| 145 | + })?; |
| 146 | + *into = Some(duration); |
| 147 | + } |
| 148 | + None => {} |
| 149 | + }; |
| 150 | + |
| 151 | + Ok(()) |
| 152 | + } |
| 153 | + |
| 154 | + type Accumulator = Option<XsdDuration>; |
| 155 | + |
| 156 | + const KIND: instant_xml::Kind = instant_xml::Kind::Scalar; |
| 157 | +} |
| 158 | + |
| 159 | +impl ToXml for XsdDuration { |
| 160 | + fn serialize<W: std::fmt::Write + ?Sized>( |
| 161 | + &self, |
| 162 | + _field: Option<instant_xml::Id<'_>>, |
| 163 | + serializer: &mut instant_xml::Serializer<W>, |
| 164 | + ) -> Result<(), instant_xml::Error> { |
| 165 | + serializer.write_str(&format_duration_inner(self))?; |
| 166 | + Ok(()) |
| 167 | + } |
| 168 | +} |
| 169 | + |
| 170 | +#[derive(Debug)] |
| 171 | +pub struct InvalidMinutesOrSeconds; |
| 172 | + |
| 173 | +impl std::error::Error for InvalidMinutesOrSeconds {} |
| 174 | + |
| 175 | +impl std::fmt::Display for InvalidMinutesOrSeconds { |
| 176 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 177 | + write!(f, "invalid minutes or seconds value") |
| 178 | + } |
| 179 | +} |
| 180 | + |
| 181 | +#[derive(Debug)] |
| 182 | +pub struct ParseError; |
| 183 | + |
| 184 | +impl std::error::Error for ParseError {} |
| 185 | + |
| 186 | +impl std::fmt::Display for ParseError { |
| 187 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 188 | + write!(f, "failed to parse xsd duration") |
| 189 | + } |
| 190 | +} |
| 191 | + |
| 192 | +/// Serialize duration to XML duration string |
| 193 | +/// |
| 194 | +/// See https://www.w3.org/TR/xmlschema11-2/#duration |
| 195 | +pub fn format_duration<D>(duration: D) -> Result<String, D::Error> |
| 196 | +where |
| 197 | + D: TryInto<XsdDuration>, |
| 198 | +{ |
| 199 | + let duration: XsdDuration = duration.try_into()?; |
| 200 | + Ok(format_duration_inner(&duration)) |
| 201 | +} |
| 202 | +/// Serialize duration to XML duration string |
| 203 | +/// |
| 204 | +/// https://www.w3.org/TR/xmlschema11-2/#f-durationCanMap |
| 205 | +fn format_duration_inner(duration: &XsdDuration) -> String { |
| 206 | + if duration.is_zero() { |
| 207 | + return "P0D".to_owned(); |
| 208 | + } |
| 209 | + |
| 210 | + let mut buf = if duration.months < 0 || duration.seconds < 0.0 { |
| 211 | + String::from("-P") |
| 212 | + } else { |
| 213 | + String::from("P") |
| 214 | + }; |
| 215 | + // https://www.w3.org/TR/xmlschema11-2/#f-duYMCan |
| 216 | + let years = (duration.months / 12) as u64; |
| 217 | + let months = (duration.months % 12) as u64; |
| 218 | + |
| 219 | + if years > 0 { |
| 220 | + buf.push_str(&format!("{}Y", years)); |
| 221 | + } |
| 222 | + if months > 0 { |
| 223 | + buf.push_str(&format!("{}M", months)); |
| 224 | + } |
| 225 | + // https://www.w3.org/TR/xmlschema11-2/#f-duDTCan |
| 226 | + if duration.seconds == 0.0 { |
| 227 | + return buf; |
| 228 | + } |
| 229 | + |
| 230 | + let days = (duration.seconds.div_euclid(86400.0)) as u64; |
| 231 | + let hours = ((duration.seconds % 86400.0).div_euclid(3600.0)) as u64; |
| 232 | + let minutes = ((duration.seconds % 3600.0).div(60.0)) as u64; |
| 233 | + let seconds = duration.seconds % 60.0; |
| 234 | + |
| 235 | + if duration.seconds != 0.0 { |
| 236 | + if days > 0 { |
| 237 | + buf.push_str(&format!("{}D", days)); |
| 238 | + } |
| 239 | + |
| 240 | + if hours == 0 && minutes == 0 && seconds == 0.0 { |
| 241 | + return buf; |
| 242 | + } |
| 243 | + |
| 244 | + buf.push('T'); |
| 245 | + |
| 246 | + if hours > 0 { |
| 247 | + buf.push_str(&format!("{}H", hours)); |
| 248 | + } |
| 249 | + if minutes > 0 { |
| 250 | + buf.push_str(&format!("{}M", minutes)); |
| 251 | + } |
| 252 | + if seconds > 0.0 { |
| 253 | + if seconds.fract() > 0.0 { |
| 254 | + buf.push_str(&format!("{:.4}S", seconds)); |
| 255 | + } else { |
| 256 | + buf.push_str(&format!("{}S", seconds.trunc() as u64)); |
| 257 | + } |
| 258 | + } |
| 259 | + } |
| 260 | + |
| 261 | + buf |
| 262 | +} |
| 263 | + |
| 264 | +#[cfg(test)] |
| 265 | +mod tests { |
| 266 | + use super::*; |
| 267 | + |
| 268 | + const DAY: u64 = 86400; |
| 269 | + |
| 270 | + #[test] |
| 271 | + fn construct() { |
| 272 | + let _ = XsdDuration::new(12, 3600.0).unwrap(); |
| 273 | + let _ = XsdDuration::new(-12, -3600.0).unwrap(); |
| 274 | + assert!(XsdDuration::new(12, -3600.0).is_err()); |
| 275 | + assert!(XsdDuration::new(-12, 3600.0).is_err()); |
| 276 | + } |
| 277 | + |
| 278 | + #[test] |
| 279 | + fn ser() { |
| 280 | + let dur = XsdDuration::new(0, (3600 + 60 + 1) as f64).unwrap(); // 1 hour, 1 minute, 1 second |
| 281 | + let s = format_duration(dur).unwrap(); |
| 282 | + assert_eq!(s, "PT1H1M1S"); |
| 283 | + |
| 284 | + let dur = XsdDuration::new(0, 0.0).unwrap(); |
| 285 | + let s = format_duration(dur).unwrap(); |
| 286 | + assert_eq!(s, "P0D"); |
| 287 | + |
| 288 | + // This is totally flawed but the spec demands it |
| 289 | + let dur = XsdDuration::new(13, (DAY + 3600 + 60 + 1) as f64).unwrap(); // 1 year, 1 month, 1 day, 1 hour, 1 minute, 1 second |
| 290 | + let s = format_duration(dur).unwrap(); |
| 291 | + assert_eq!(s, "P1Y1M1DT1H1M1S"); |
| 292 | + |
| 293 | + let dur = XsdDuration::new(13, (5 * DAY) as f64).unwrap(); // 1 year, 1 month, 5 days |
| 294 | + let s = format_duration(dur).unwrap(); |
| 295 | + assert_eq!(s, "P1Y1M5D"); |
| 296 | + } |
| 297 | + |
| 298 | + #[test] |
| 299 | + fn deser() { |
| 300 | + let s = "PT1H1M1S"; |
| 301 | + let dur: XsdDuration = s.parse().unwrap(); |
| 302 | + assert_eq!(dur, XsdDuration::new(0, (3600 + 60 + 1) as f64).unwrap()); |
| 303 | + |
| 304 | + let s = "P0D"; |
| 305 | + let dur: XsdDuration = s.parse().unwrap(); |
| 306 | + assert_eq!(dur, XsdDuration::new(0, 0.0).unwrap()); |
| 307 | + let s = "P1Y1M1DT1H1M1S"; |
| 308 | + let dur: XsdDuration = s.parse().unwrap(); |
| 309 | + assert_eq!( |
| 310 | + dur, |
| 311 | + XsdDuration::new(12 * 30, (30 * DAY + DAY + 3600 + 60 + 1) as f64).unwrap() |
| 312 | + ); |
| 313 | + |
| 314 | + let s = "P1Y1M5DT0H0M0S"; |
| 315 | + let dur: XsdDuration = s.parse().unwrap(); |
| 316 | + assert_eq!( |
| 317 | + dur, |
| 318 | + XsdDuration::new(12 * 30, (30 * DAY + 5 * DAY) as f64).unwrap() |
| 319 | + ); |
| 320 | + } |
| 321 | +} |
0 commit comments