oxedyne/fe2o3/fe2o3_datime/src/time/zone.rs
25.6 KiB, 139 runs
created by r1870400018:6399, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 2 | //! Anthropic Claude |
| 3 | |
| 4 | use crate::{ |
| 5 | time::tzif::{TZifData, TZifParser, LocalTimeResult}, |
| 6 | }; |
| 7 | |
| 8 | use oxedyne_fe2o3_core::prelude::*; |
| 9 | |
| 10 | use std::{ |
| 11 | collections::HashMap, |
| 12 | fmt::{self, Display}, |
| 13 | fs, |
| 14 | path::Path, |
| 15 | sync::OnceLock, |
| 16 | }; |
| 17 | |
| 18 | /// A zone is UTC, a fixed offset, or a set of DST rules. The rules for the |
| 19 | /// major zones are embedded rather than taken from a timezone crate, with the |
| 20 | /// host's own zoneinfo tree as the fallback for everything else. |
| 21 | /// |
| 22 | /// ```ignore |
| 23 | /// use oxedyne_fe2o3_datime::time::CalClockZoneres!(); |
| 24 | /// |
| 25 | /// // Create common timezones |
| 26 | /// let utc = CalClockZone::utc()res!(); |
| 27 | /// let eastern = res!(CalClockZone::new("America/New_York"))res!(); |
| 28 | /// let local = CalClockZone::here()res!(); |
| 29 | /// |
| 30 | /// // Calculate timezone offset for specific time |
| 31 | /// let offset_ms = res!(eastern.offset_millis_at_time(utc_timestamp))res!(); |
| 32 | /// let is_dst = res!(eastern.in_daylight_time(utc_timestamp))res!(); |
| 33 | /// ``` |
| 34 | #[derive(Clone, Debug)] |
| 35 | pub struct CalClockZone { |
| 36 | id: String, |
| 37 | zone_data: TimezoneData, |
| 38 | tzif_data: Option<TZifData>, |
| 39 | } |
| 40 | |
| 41 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] |
| 42 | enum TimezoneData { |
| 43 | Utc, |
| 44 | Fixed(i32), // seconds east of UTC |
| 45 | RuleBased { |
| 46 | base_offset: i32, // seconds east of UTC, before DST |
| 47 | dst_rules: Vec<DstRule>, |
| 48 | }, |
| 49 | #[allow(dead_code)] |
| 50 | Local, |
| 51 | } |
| 52 | |
| 53 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] |
| 54 | struct DstRule { |
| 55 | start_year: i32, |
| 56 | end_year: Option<i32>, // None while the rule is still in force |
| 57 | dst_start: DstTransition, |
| 58 | dst_end: DstTransition, |
| 59 | dst_offset: i32, // seconds, usually 3600 |
| 60 | } |
| 61 | |
| 62 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] |
| 63 | struct DstTransition { |
| 64 | month: u8, // 1-12 |
| 65 | day_spec: DaySpec, |
| 66 | hour: u8, // 0-23 |
| 67 | } |
| 68 | |
| 69 | #[derive(Clone, Debug, Eq, Hash, PartialEq)] |
| 70 | enum DaySpec { |
| 71 | #[allow(dead_code)] |
| 72 | Day(u8), // day of month |
| 73 | LastWeekday(u8), // 0=Sunday, 1=Monday, etc. |
| 74 | WeekdayOnOrAfter { weekday: u8, day: u8 }, |
| 75 | } |
| 76 | |
| 77 | static TIMEZONE_DB: OnceLock<HashMap<String, TimezoneData>> = OnceLock::new(); |
| 78 | |
| 79 | fn init_timezone_db() -> HashMap<String, TimezoneData> { |
| 80 | let mut db = HashMap::new(); |
| 81 | |
| 82 | // UTC and GMT |
| 83 | db.insert("UTC".to_string(), TimezoneData::Utc); |
| 84 | db.insert("GMT".to_string(), TimezoneData::Utc); |
| 85 | |
| 86 | // Fixed offset zones |
| 87 | for offset_hours in -12..=14 { |
| 88 | let offset_seconds = offset_hours * 3600; |
| 89 | let id = if offset_hours >= 0 { |
| 90 | format!("GMT+{}", offset_hours) |
| 91 | } else { |
| 92 | format!("GMT{}", offset_hours) |
| 93 | }; |
| 94 | db.insert(id, TimezoneData::Fixed(offset_seconds)); |
| 95 | } |
| 96 | |
| 97 | // Major timezone zones with DST rules |
| 98 | db.insert("America/New_York".to_string(), TimezoneData::RuleBased { |
| 99 | base_offset: -5 * 3600, // EST |
| 100 | dst_rules: vec![ |
| 101 | DstRule { |
| 102 | start_year: 2007, |
| 103 | end_year: None, |
| 104 | dst_start: DstTransition { |
| 105 | month: 3, |
| 106 | day_spec: DaySpec::WeekdayOnOrAfter { weekday: 0, day: 8 }, // 2nd Sunday |
| 107 | hour: 2, |
| 108 | }, |
| 109 | dst_end: DstTransition { |
| 110 | month: 11, |
| 111 | day_spec: DaySpec::WeekdayOnOrAfter { weekday: 0, day: 1 }, // 1st Sunday |
| 112 | hour: 2, |
| 113 | }, |
| 114 | dst_offset: 3600, |
| 115 | }, |
| 116 | ], |
| 117 | }); |
| 118 | |
| 119 | db.insert("Europe/London".to_string(), TimezoneData::RuleBased { |
| 120 | base_offset: 0, // GMT |
| 121 | dst_rules: vec![ |
| 122 | DstRule { |
| 123 | start_year: 1996, |
| 124 | end_year: None, |
| 125 | dst_start: DstTransition { |
| 126 | month: 3, |
| 127 | day_spec: DaySpec::LastWeekday(0), // Last Sunday |
| 128 | hour: 1, |
| 129 | }, |
| 130 | dst_end: DstTransition { |
| 131 | month: 10, |
| 132 | day_spec: DaySpec::LastWeekday(0), // Last Sunday |
| 133 | hour: 2, |
| 134 | }, |
| 135 | dst_offset: 3600, |
| 136 | }, |
| 137 | ], |
| 138 | }); |
| 139 | |
| 140 | db.insert("Australia/Sydney".to_string(), TimezoneData::RuleBased { |
| 141 | base_offset: 10 * 3600, // AEST |
| 142 | dst_rules: vec![ |
| 143 | DstRule { |
| 144 | start_year: 2008, |
| 145 | end_year: None, |
| 146 | dst_start: DstTransition { |
| 147 | month: 10, |
| 148 | day_spec: DaySpec::WeekdayOnOrAfter { weekday: 0, day: 1 }, // 1st Sunday |
| 149 | hour: 2, |
| 150 | }, |
| 151 | dst_end: DstTransition { |
| 152 | month: 4, |
| 153 | day_spec: DaySpec::WeekdayOnOrAfter { weekday: 0, day: 1 }, // 1st Sunday |
| 154 | hour: 3, |
| 155 | }, |
| 156 | dst_offset: 3600, |
| 157 | }, |
| 158 | ], |
| 159 | }); |
| 160 | |
| 161 | db |
| 162 | } |
| 163 | |
| 164 | fn get_timezone_db() -> &'static HashMap<String, TimezoneData> { |
| 165 | TIMEZONE_DB.get_or_init(init_timezone_db) |
| 166 | } |
| 167 | |
| 168 | impl CalClockZone { |
| 169 | /// Accepts a standard identifier, a named offset such as "GMT+5", or a |
| 170 | /// numeric one such as "+0530". An unrecognised name resolves to UTC rather |
| 171 | /// than failing, which is what the Java original does. |
| 172 | /// |
| 173 | /// ```ignore |
| 174 | /// let utc = res!(CalClockZone::new("UTC"))res!(); |
| 175 | /// let eastern = res!(CalClockZone::new("America/New_York"))res!(); |
| 176 | /// let fixed = res!(CalClockZone::new("GMT+5"))res!(); |
| 177 | /// ``` |
| 178 | pub fn new<S: Into<String>>(zone_id: S) -> Outcome<Self> { |
| 179 | let id = zone_id.into(); |
| 180 | |
| 181 | // Try system timezone data first (Jiff-style integration) |
| 182 | if let Ok(Some(system_zone)) = crate::time::system::SystemTimezoneManager::global() |
| 183 | .load_system_timezone(&id) { |
| 184 | return Ok(system_zone); |
| 185 | } |
| 186 | |
| 187 | // Handle UTC -> GMT conversion for consistency |
| 188 | let lookup_id = if id == "UTC" { "GMT" } else { &id }; |
| 189 | |
| 190 | if let Some(zone_data) = get_timezone_db().get(lookup_id) { |
| 191 | return Ok(Self { |
| 192 | id: id.clone(), |
| 193 | zone_data: zone_data.clone(), |
| 194 | tzif_data: None, |
| 195 | }); |
| 196 | } |
| 197 | |
| 198 | // Try to parse as fixed offset |
| 199 | if let Ok(offset_seconds) = Self::parse_fixed_offset(&id) { |
| 200 | return Ok(Self { |
| 201 | id, |
| 202 | zone_data: TimezoneData::Fixed(offset_seconds), |
| 203 | tzif_data: None, |
| 204 | }); |
| 205 | } |
| 206 | |
| 207 | // A name the embedded table does not hold may still be on the system's |
| 208 | // own zoneinfo tree, whose rules are better than a silent zero offset. |
| 209 | if let Ok(zone) = Self::from_zoneinfo_name(&id) { |
| 210 | return Ok(zone); |
| 211 | } |
| 212 | |
| 213 | // Default to UTC for unrecognised zones (matches Java behaviour) |
| 214 | Ok(Self { |
| 215 | id, |
| 216 | zone_data: TimezoneData::Utc, |
| 217 | tzif_data: None, |
| 218 | }) |
| 219 | } |
| 220 | |
| 221 | pub fn utc() -> Self { |
| 222 | Self { |
| 223 | id: "UTC".to_string(), |
| 224 | zone_data: TimezoneData::Utc, |
| 225 | tzif_data: None, |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | /// GMT is held as UTC here: zero offset, no transitions. |
| 230 | pub fn gmt() -> Self { |
| 231 | Self { |
| 232 | id: "GMT".to_string(), |
| 233 | zone_data: TimezoneData::Utc, |
| 234 | tzif_data: None, |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | /// As new, but never consults the system timezone database, so the result |
| 239 | /// does not vary with the host. |
| 240 | pub fn new_embedded<S: Into<String>>(zone_id: S) -> Outcome<Self> { |
| 241 | let id = zone_id.into(); |
| 242 | |
| 243 | // Handle UTC -> GMT conversion for consistency |
| 244 | let lookup_id = if id == "UTC" { "GMT" } else { &id }; |
| 245 | |
| 246 | if let Some(zone_data) = get_timezone_db().get(lookup_id) { |
| 247 | return Ok(Self { |
| 248 | id: id.clone(), |
| 249 | zone_data: zone_data.clone(), |
| 250 | tzif_data: None, |
| 251 | }); |
| 252 | } |
| 253 | |
| 254 | // Try to parse as fixed offset |
| 255 | if let Ok(offset_seconds) = Self::parse_fixed_offset(&id) { |
| 256 | return Ok(Self { |
| 257 | id, |
| 258 | zone_data: TimezoneData::Fixed(offset_seconds), |
| 259 | tzif_data: None, |
| 260 | }); |
| 261 | } |
| 262 | |
| 263 | // A name the embedded table does not hold may still be on the system's |
| 264 | // own zoneinfo tree, whose rules are better than a silent zero offset. |
| 265 | if let Ok(zone) = Self::from_zoneinfo_name(&id) { |
| 266 | return Ok(zone); |
| 267 | } |
| 268 | |
| 269 | // Default to UTC for unrecognised zones (matches Java behaviour) |
| 270 | Ok(Self { |
| 271 | id, |
| 272 | zone_data: TimezoneData::Utc, |
| 273 | tzif_data: None, |
| 274 | }) |
| 275 | } |
| 276 | |
| 277 | /// The host's own zone, read from TZ, /etc/localtime or /etc/timezone, and |
| 278 | /// UTC when none of them answer. |
| 279 | /// |
| 280 | /// ```ignore |
| 281 | /// let local = CalClockZone::here()res!(); |
| 282 | /// println!("Local timezone: {}", local.id())res!(); |
| 283 | /// ``` |
| 284 | pub fn here() -> Self { |
| 285 | // Try to detect system timezone |
| 286 | if let Ok(local_zone) = Self::detect_system_timezone() { |
| 287 | return local_zone; |
| 288 | } |
| 289 | |
| 290 | // Fall back to UTC |
| 291 | Self::utc() |
| 292 | } |
| 293 | |
| 294 | pub fn local() -> Self { |
| 295 | Self::here() |
| 296 | } |
| 297 | |
| 298 | pub fn from_tzif_data<S: Into<String>>(zone_id: S, tzif_data: TZifData) -> Outcome<Self> { |
| 299 | let id = zone_id.into(); |
| 300 | |
| 301 | // Determine the appropriate TimezoneData based on TZif content |
| 302 | let zone_data = if tzif_data.local_time_types.is_empty() { |
| 303 | TimezoneData::Utc |
| 304 | } else if tzif_data.transition_times.is_empty() && tzif_data.local_time_types.len() == 1 { |
| 305 | // Single fixed offset |
| 306 | TimezoneData::Fixed(tzif_data.local_time_types[0].utc_offset) |
| 307 | } else { |
| 308 | // Rule-based timezone with transitions |
| 309 | // For now, we'll use the embedded rule system but prefer TZif data |
| 310 | TimezoneData::RuleBased { |
| 311 | base_offset: tzif_data.local_time_types.get(0).map(|t| t.utc_offset).unwrap_or(0), |
| 312 | dst_rules: Vec::new(), // TZif data will be used instead |
| 313 | } |
| 314 | }; |
| 315 | |
| 316 | Ok(Self { |
| 317 | id, |
| 318 | zone_data, |
| 319 | tzif_data: Some(tzif_data), |
| 320 | }) |
| 321 | } |
| 322 | |
| 323 | pub fn id(&self) -> &str { |
| 324 | &self.id |
| 325 | } |
| 326 | |
| 327 | /// Milliseconds east of UTC at that instant, so the DST rules in force then |
| 328 | /// are the ones applied. `CalClockZoneCached` has a caching form. |
| 329 | /// |
| 330 | /// ```ignore |
| 331 | /// let eastern = res!(CalClockZone::new("America/New_York"))res!(); |
| 332 | /// let summer_offset = res!(eastern.offset_millis_at_time(summer_timestamp))res!(); |
| 333 | /// let winter_offset = res!(eastern.offset_millis_at_time(winter_timestamp))res!(); |
| 334 | /// assert_eq!(summer_offset, -4 * 3600 * 1000)res!(); // EDT |
| 335 | /// assert_eq!(winter_offset, -5 * 3600 * 1000)res!(); // EST |
| 336 | /// ``` |
| 337 | pub fn offset_millis_at_time(&self, utc_millis: i64) -> Outcome<i32> { |
| 338 | // Use TZif data if available for accurate calculations |
| 339 | if let Some(ref tzif_data) = self.tzif_data { |
| 340 | let utc_seconds = utc_millis / 1000; |
| 341 | return tzif_data.get_offset_at_utc(utc_seconds).map(|offset| offset * 1000); |
| 342 | } |
| 343 | |
| 344 | // Fall back to embedded timezone rules |
| 345 | match &self.zone_data { |
| 346 | TimezoneData::Utc => Ok(0), |
| 347 | TimezoneData::Fixed(offset_seconds) => Ok(offset_seconds * 1000), |
| 348 | TimezoneData::RuleBased { base_offset, dst_rules } => { |
| 349 | let base_offset_millis = base_offset * 1000; |
| 350 | |
| 351 | // Check if we're in daylight saving time |
| 352 | let dst_offset_result = res!(self.dst_offset_at_time(utc_millis, dst_rules)); |
| 353 | if let Some(dst_offset) = dst_offset_result { |
| 354 | Ok(base_offset_millis + dst_offset * 1000) |
| 355 | } else { |
| 356 | Ok(base_offset_millis) |
| 357 | } |
| 358 | }, |
| 359 | TimezoneData::Local => { |
| 360 | // For local timezone, try to calculate offset using system APIs |
| 361 | self.system_offset_at_time(utc_millis) |
| 362 | }, |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | /// Milliseconds east of UTC ignoring any DST in force, as Java's |
| 367 | /// TimeZone.getRawOffset does. |
| 368 | pub fn raw_offset_millis(&self) -> i32 { |
| 369 | match &self.zone_data { |
| 370 | TimezoneData::Utc => 0, |
| 371 | TimezoneData::Fixed(offset_seconds) => offset_seconds * 1000, |
| 372 | TimezoneData::RuleBased { base_offset, .. } => base_offset * 1000, |
| 373 | TimezoneData::Local => 0, // Fallback |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | pub fn offset_seconds(&self, timestamp_secs: i64) -> Outcome<i32> { |
| 378 | let offset_millis = res!(self.offset_millis_at_time(timestamp_secs * 1000)); |
| 379 | Ok(offset_millis / 1000) |
| 380 | } |
| 381 | |
| 382 | pub fn in_daylight_time(&self, utc_millis: i64) -> Outcome<bool> { |
| 383 | // Use TZif data if available for accurate DST detection |
| 384 | if let Some(ref tzif_data) = self.tzif_data { |
| 385 | let utc_seconds = utc_millis / 1000; |
| 386 | return tzif_data.is_dst_at_utc(utc_seconds); |
| 387 | } |
| 388 | |
| 389 | // Fall back to embedded timezone rules |
| 390 | match &self.zone_data { |
| 391 | TimezoneData::Utc | TimezoneData::Fixed(_) => Ok(false), |
| 392 | TimezoneData::RuleBased { dst_rules, .. } => { |
| 393 | let dst_result = res!(self.dst_offset_at_time(utc_millis, dst_rules)); |
| 394 | Ok(dst_result.is_some()) |
| 395 | }, |
| 396 | TimezoneData::Local => Ok(false), // Fallback |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | /// Ambiguous covers the autumn fold, where the local time is reached twice |
| 401 | /// and both are returned; None the spring gap, where it is never reached. |
| 402 | pub fn utc_to_local(&self, utc_millis: i64) -> LocalTimeResult<i64> { |
| 403 | if let Some(ref tzif_data) = self.tzif_data { |
| 404 | let utc_seconds = utc_millis / 1000; |
| 405 | match tzif_data.utc_to_local(utc_seconds) { |
| 406 | LocalTimeResult::Single((local_seconds, _)) => { |
| 407 | LocalTimeResult::Single(local_seconds * 1000) |
| 408 | }, |
| 409 | LocalTimeResult::Ambiguous((local1, _), (local2, _)) => { |
| 410 | LocalTimeResult::Ambiguous(local1 * 1000, local2 * 1000) |
| 411 | }, |
| 412 | LocalTimeResult::None => LocalTimeResult::None, |
| 413 | } |
| 414 | } else { |
| 415 | // Fall back to simple offset calculation |
| 416 | match self.offset_millis_at_time(utc_millis) { |
| 417 | Ok(offset) => LocalTimeResult::Single(utc_millis + offset as i64), |
| 418 | Err(_) => LocalTimeResult::None, |
| 419 | } |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | /// The inverse of utc_to_local, and ambiguous or absent over the same two |
| 424 | /// transitions. |
| 425 | pub fn local_to_utc(&self, local_millis: i64) -> LocalTimeResult<i64> { |
| 426 | if let Some(ref tzif_data) = self.tzif_data { |
| 427 | let local_seconds = local_millis / 1000; |
| 428 | match tzif_data.local_to_utc(local_seconds) { |
| 429 | LocalTimeResult::Single((utc_seconds, _)) => { |
| 430 | LocalTimeResult::Single(utc_seconds * 1000) |
| 431 | }, |
| 432 | LocalTimeResult::Ambiguous((utc1, _), (utc2, _)) => { |
| 433 | LocalTimeResult::Ambiguous(utc1 * 1000, utc2 * 1000) |
| 434 | }, |
| 435 | LocalTimeResult::None => LocalTimeResult::None, |
| 436 | } |
| 437 | } else { |
| 438 | // Fall back to simple offset calculation |
| 439 | // This is less accurate for DST transitions but provides basic functionality |
| 440 | match self.offset_millis_at_time(local_millis) { |
| 441 | Ok(offset) => LocalTimeResult::Single(local_millis - offset as i64), |
| 442 | Err(_) => LocalTimeResult::None, |
| 443 | } |
| 444 | } |
| 445 | } |
| 446 | |
| 447 | /// None unless the zone came from IANA data. |
| 448 | pub fn tzif_data(&self) -> Option<&TZifData> { |
| 449 | self.tzif_data.as_ref() |
| 450 | } |
| 451 | |
| 452 | pub fn display_name(&self) -> &str { |
| 453 | &self.id |
| 454 | } |
| 455 | |
| 456 | fn parse_fixed_offset(offset_str: &str) -> Outcome<i32> { |
| 457 | // Handle GMT+N or GMT-N format |
| 458 | if let Some(offset_part) = offset_str.strip_prefix("GMT") { |
| 459 | return Self::parse_offset_value(offset_part); |
| 460 | } |
| 461 | |
| 462 | // Handle UTC+N or UTC-N format |
| 463 | if let Some(offset_part) = offset_str.strip_prefix("UTC") { |
| 464 | return Self::parse_offset_value(offset_part); |
| 465 | } |
| 466 | |
| 467 | // Handle direct +/-HHMM format |
| 468 | if offset_str.starts_with('+') || offset_str.starts_with('-') { |
| 469 | return Self::parse_offset_value(offset_str); |
| 470 | } |
| 471 | |
| 472 | Err(err!("Invalid offset format: {}", offset_str; Invalid, Input)) |
| 473 | } |
| 474 | |
| 475 | fn parse_offset_value(offset_str: &str) -> Outcome<i32> { |
| 476 | if offset_str.is_empty() { |
| 477 | return Ok(0); |
| 478 | } |
| 479 | |
| 480 | let (sign, digits) = if let Some(digits) = offset_str.strip_prefix('+') { |
| 481 | (1, digits) |
| 482 | } else if let Some(digits) = offset_str.strip_prefix('-') { |
| 483 | (-1, digits) |
| 484 | } else { |
| 485 | return Err(err!("Offset must start with + or -: {}", offset_str; Invalid, Input)); |
| 486 | }; |
| 487 | |
| 488 | let offset_seconds = if digits.len() == 1 || digits.len() == 2 { |
| 489 | // Simple hour offset like "+5" or "+12" |
| 490 | let hours: i32 = res!(digits.parse().map_err(|_| |
| 491 | err!("Invalid hour value: {}", digits; Invalid, Input))); |
| 492 | hours * 3600 |
| 493 | } else if digits.len() == 4 { |
| 494 | // HHMM format like "+0530" |
| 495 | let hours: i32 = res!(digits[..2].parse().map_err(|_| |
| 496 | err!("Invalid hour value: {}", &digits[..2]; Invalid, Input))); |
| 497 | let minutes: i32 = res!(digits[2..].parse().map_err(|_| |
| 498 | err!("Invalid minute value: {}", &digits[2..]; Invalid, Input))); |
| 499 | hours * 3600 + minutes * 60 |
| 500 | } else { |
| 501 | return Err(err!("Invalid offset format length: {}", digits; Invalid, Input)); |
| 502 | }; |
| 503 | |
| 504 | Ok(sign * offset_seconds) |
| 505 | } |
| 506 | |
| 507 | fn detect_system_timezone() -> Outcome<Self> { |
| 508 | // TZ first, as POSIX says: an optional leading colon, then either a |
| 509 | // zone name or a path to a TZif file. |
| 510 | if let Ok(tz) = std::env::var("TZ") { |
| 511 | if !tz.is_empty() { |
| 512 | let name = tz.strip_prefix(':').unwrap_or(&tz); |
| 513 | if name.starts_with('/') { |
| 514 | if let Ok(zone) = Self::from_tzif_file(name, Path::new(name)) { |
| 515 | return Ok(zone); |
| 516 | } |
| 517 | } |
| 518 | if let Ok(zone) = Self::from_zoneinfo_name(name) { |
| 519 | return Ok(zone); |
| 520 | } |
| 521 | return Self::new(name); |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | // /etc/localtime: on every modern Linux and macOS a symlink into the |
| 526 | // zoneinfo tree, and a TZif file either way. The symlink target names |
| 527 | // the zone; the bytes carry its rules, so the answer is right even for |
| 528 | // a zone the embedded table does not hold. Reading the machine's own |
| 529 | // setting is the whole purpose of local(), so no consent machinery |
| 530 | // applies here -- that gate belongs to the manager that scans zone |
| 531 | // data wholesale. |
| 532 | let localtime = Path::new("/etc/localtime"); |
| 533 | if localtime.exists() { |
| 534 | let id = fs::read_link(localtime).ok() |
| 535 | .and_then(|target| Self::zone_name_from_path(&target)) |
| 536 | .unwrap_or_else(|| "Local".to_string()); |
| 537 | if let Ok(zone) = Self::from_tzif_file(&id, localtime) { |
| 538 | return Ok(zone); |
| 539 | } |
| 540 | } |
| 541 | |
| 542 | // /etc/timezone: the Debian name file, no longer shipped everywhere |
| 543 | // (Ubuntu 25.10 dropped it) but still authoritative where it exists. |
| 544 | if let Ok(name) = fs::read_to_string("/etc/timezone") { |
| 545 | let name = name.trim(); |
| 546 | if !name.is_empty() { |
| 547 | if let Ok(zone) = Self::from_zoneinfo_name(name) { |
| 548 | return Ok(zone); |
| 549 | } |
| 550 | return Self::new(name); |
| 551 | } |
| 552 | } |
| 553 | |
| 554 | Err(err!("Could not detect system timezone"; System)) |
| 555 | } |
| 556 | |
| 557 | fn from_tzif_file(id: &str, path: &Path) -> Outcome<Self> { |
| 558 | let mut parser = TZifParser::new(); |
| 559 | res!(parser.load_from_file(path)); |
| 560 | match parser.timezone_data() { |
| 561 | Some(data) => Self::from_tzif_data(id, data.clone()), |
| 562 | None => Err(err!( |
| 563 | "The TZif file {:?} parsed to no timezone data.", path; |
| 564 | System, Missing, Data)), |
| 565 | } |
| 566 | } |
| 567 | |
| 568 | fn from_zoneinfo_name(name: &str) -> Outcome<Self> { |
| 569 | // The name may have come from the environment; keep it inside the |
| 570 | // tree. |
| 571 | if name.starts_with('/') || name.contains("..") { |
| 572 | return Err(err!( |
| 573 | "'{}' is not a plain zone name.", name; |
| 574 | Invalid, Input)); |
| 575 | } |
| 576 | for base in ["/usr/share/zoneinfo", "/usr/lib/zoneinfo", "/etc/zoneinfo"] { |
| 577 | let path = Path::new(base).join(name); |
| 578 | if path.is_file() { |
| 579 | return Self::from_tzif_file(name, &path); |
| 580 | } |
| 581 | } |
| 582 | Err(err!( |
| 583 | "No zoneinfo file for '{}' on this system.", name; |
| 584 | System, Missing)) |
| 585 | } |
| 586 | |
| 587 | /// The zone name in a zoneinfo path: the components after `zoneinfo`, |
| 588 | /// joined again -- `/usr/share/zoneinfo/Australia/Perth` names |
| 589 | /// `Australia/Perth`. |
| 590 | fn zone_name_from_path(path: &Path) -> Option<String> { |
| 591 | let mut parts: Vec<String> = Vec::new(); |
| 592 | let mut seen = false; |
| 593 | for component in path.components() { |
| 594 | let text = component.as_os_str().to_string_lossy(); |
| 595 | if seen { |
| 596 | parts.push(text.into_owned()); |
| 597 | } else if text == "zoneinfo" { |
| 598 | seen = true; |
| 599 | } |
| 600 | } |
| 601 | if parts.is_empty() { |
| 602 | None |
| 603 | } else { |
| 604 | Some(parts.join("/")) |
| 605 | } |
| 606 | } |
| 607 | |
| 608 | fn dst_offset_at_time(&self, utc_millis: i64, dst_rules: &[DstRule]) -> Outcome<Option<i32>> { |
| 609 | // Convert UTC milliseconds to a date for rule evaluation |
| 610 | let utc_date = res!(self.millis_to_date(utc_millis)); |
| 611 | |
| 612 | // Find applicable DST rule for this year |
| 613 | let applicable_rule = dst_rules.iter() |
| 614 | .find(|rule| { |
| 615 | rule.start_year <= utc_date.year && |
| 616 | rule.end_year.map_or(true, |end| utc_date.year <= end) |
| 617 | }); |
| 618 | |
| 619 | if let Some(rule) = applicable_rule { |
| 620 | let dst_start = res!(self.calculate_transition_time(&rule.dst_start, utc_date.year)); |
| 621 | let dst_end = res!(self.calculate_transition_time(&rule.dst_end, utc_date.year)); |
| 622 | |
| 623 | // Check if current time is within DST period |
| 624 | if utc_millis >= dst_start && utc_millis < dst_end { |
| 625 | Ok(Some(rule.dst_offset)) |
| 626 | } else { |
| 627 | Ok(None) |
| 628 | } |
| 629 | } else { |
| 630 | Ok(None) |
| 631 | } |
| 632 | } |
| 633 | |
| 634 | fn millis_to_date(&self, utc_millis: i64) -> Outcome<SimpleDate> { |
| 635 | // Convert milliseconds to seconds |
| 636 | let seconds = utc_millis / 1000; |
| 637 | |
| 638 | // Calculate days since Unix epoch (January 1, 1970) |
| 639 | let days_since_epoch = seconds / 86400; // 86400 seconds in a day |
| 640 | |
| 641 | // Calculate the year using proper calendar arithmetic |
| 642 | let mut year = 1970; |
| 643 | let mut remaining_days = days_since_epoch; |
| 644 | |
| 645 | // Handle negative days (before 1970) |
| 646 | if remaining_days < 0 { |
| 647 | while remaining_days < 0 { |
| 648 | year -= 1; |
| 649 | let days_in_year = if Self::is_leap_year(year) { 366 } else { 365 }; |
| 650 | remaining_days += days_in_year; |
| 651 | } |
| 652 | } else { |
| 653 | // Handle positive days (after 1970) |
| 654 | loop { |
| 655 | let days_in_year = if Self::is_leap_year(year) { 366 } else { 365 }; |
| 656 | if remaining_days < days_in_year { |
| 657 | break; |
| 658 | } |
| 659 | remaining_days -= days_in_year; |
| 660 | year += 1; |
| 661 | } |
| 662 | } |
| 663 | |
| 664 | Ok(SimpleDate { year: year as i32 }) |
| 665 | } |
| 666 | |
| 667 | fn is_leap_year(year: i64) -> bool { |
| 668 | (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) |
| 669 | } |
| 670 | |
| 671 | fn calculate_transition_time(&self, transition: &DstTransition, year: i32) -> Outcome<i64> { |
| 672 | // Calculate the day of the transition |
| 673 | let day = res!(self.calculate_transition_day(&transition.day_spec, transition.month, year)); |
| 674 | |
| 675 | // Convert to UTC milliseconds |
| 676 | let days_since_epoch = res!(self.days_since_epoch(year, transition.month, day)); |
| 677 | let transition_millis = days_since_epoch * 86400 * 1000 + (transition.hour as i64) * 3600 * 1000; |
| 678 | |
| 679 | Ok(transition_millis) |
| 680 | } |
| 681 | |
| 682 | fn calculate_transition_day(&self, day_spec: &DaySpec, month: u8, year: i32) -> Outcome<u8> { |
| 683 | match day_spec { |
| 684 | DaySpec::Day(day) => Ok(*day), |
| 685 | DaySpec::LastWeekday(weekday) => { |
| 686 | // Find the last occurrence of the specified weekday in the month |
| 687 | let days_in_month = self.days_in_month(month, year); |
| 688 | let mut day = days_in_month; |
| 689 | |
| 690 | // Work backwards from the last day of month |
| 691 | while day >= 1 { |
| 692 | let day_of_week = self.day_of_week(year, month, day); |
| 693 | if day_of_week == *weekday { |
| 694 | return Ok(day); |
| 695 | } |
| 696 | day -= 1; |
| 697 | } |
| 698 | |
| 699 | Err(err!("Could not find weekday {} in month {}/{}", weekday, month, year; Invalid)) |
| 700 | }, |
| 701 | DaySpec::WeekdayOnOrAfter { weekday, day } => { |
| 702 | // Find the first occurrence of weekday on or after the specified day |
| 703 | let days_in_month = self.days_in_month(month, year); |
| 704 | let mut current_day = *day; |
| 705 | |
| 706 | while current_day <= days_in_month { |
| 707 | let day_of_week = self.day_of_week(year, month, current_day); |
| 708 | if day_of_week == *weekday { |
| 709 | return Ok(current_day); |
| 710 | } |
| 711 | current_day += 1; |
| 712 | } |
| 713 | |
| 714 | Err(err!("Could not find weekday {} on or after day {} in month {}/{}", |
| 715 | weekday, day, month, year; Invalid)) |
| 716 | } |
| 717 | } |
| 718 | } |
| 719 | |
| 720 | fn days_since_epoch(&self, year: i32, month: u8, day: u8) -> Outcome<i64> { |
| 721 | let mut days = 0i64; |
| 722 | |
| 723 | // Add days for complete years from 1970 |
| 724 | for y in 1970..year { |
| 725 | days += if Self::is_leap_year(y as i64) { 366 } else { 365 }; |
| 726 | } |
| 727 | |
| 728 | // Add days for complete months in the target year |
| 729 | for m in 1..month { |
| 730 | days += self.days_in_month(m, year) as i64; |
| 731 | } |
| 732 | |
| 733 | // Add remaining days (subtract 1 because day 1 = 0 days since start of month) |
| 734 | days += (day - 1) as i64; |
| 735 | |
| 736 | Ok(days) |
| 737 | } |
| 738 | |
| 739 | fn days_in_month(&self, month: u8, year: i32) -> u8 { |
| 740 | match month { |
| 741 | 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, |
| 742 | 4 | 6 | 9 | 11 => 30, |
| 743 | 2 => if Self::is_leap_year(year as i64) { 29 } else { 28 }, |
| 744 | _ => 0, // Invalid month |
| 745 | } |
| 746 | } |
| 747 | |
| 748 | /// 0 = Sunday. |
| 749 | fn day_of_week(&self, year: i32, month: u8, day: u8) -> u8 { |
| 750 | // Use Zeller's congruence algorithm |
| 751 | let (q, m, k, j) = if month < 3 { |
| 752 | (day as i32, month as i32 + 12, (year - 1) % 100, (year - 1) / 100) |
| 753 | } else { |
| 754 | (day as i32, month as i32, year % 100, year / 100) |
| 755 | }; |
| 756 | |
| 757 | let h = (q + ((13 * (m + 1)) / 5) + k + (k / 4) + (j / 4) - 2 * j) % 7; |
| 758 | |
| 759 | // Convert to our format (0=Sunday) |
| 760 | ((h + 5) % 7) as u8 |
| 761 | } |
| 762 | |
| 763 | fn system_offset_at_time(&self, _utc_millis: i64) -> Outcome<i32> { |
| 764 | // Try to get system offset using standard library SystemTime |
| 765 | |
| 766 | // For now, return 0 (UTC) as fallback since proper platform-specific |
| 767 | // timezone offset detection requires platform-specific APIs |
| 768 | // This could be enhanced with platform-specific implementations: |
| 769 | // - Windows: GetTimeZoneInformation |
| 770 | // - Unix/Linux: /etc/localtime, TZ environment variable parsing |
| 771 | // - macOS: NSTimeZone currentTimeZone |
| 772 | |
| 773 | // Basic implementation: check TZ environment variable |
| 774 | if let Ok(tz) = std::env::var("TZ") { |
| 775 | if tz == "UTC" || tz == "GMT" { |
| 776 | return Ok(0); |
| 777 | } |
| 778 | // Parse simple offset formats like "GMT+5" or "UTC-3" |
| 779 | if let Ok(offset) = self.parse_simple_offset(&tz) { |
| 780 | return Ok(offset); |
| 781 | } |
| 782 | } |
| 783 | |
| 784 | // Fallback to UTC offset |
| 785 | Ok(0) |
| 786 | } |
| 787 | |
| 788 | fn parse_simple_offset(&self, tz: &str) -> Outcome<i32> { |
| 789 | if tz.starts_with("GMT") || tz.starts_with("UTC") { |
| 790 | let offset_part = &tz[3..]; |
| 791 | if offset_part.is_empty() { |
| 792 | return Ok(0); |
| 793 | } |
| 794 | |
| 795 | let sign = if offset_part.starts_with('+') { |
| 796 | 1 |
| 797 | } else if offset_part.starts_with('-') { |
| 798 | -1 |
| 799 | } else { |
| 800 | return Err(err!("Invalid timezone offset format: {}", tz; Invalid, Input)); |
| 801 | }; |
| 802 | |
| 803 | let hours: i32 = res!(offset_part[1..].parse().map_err(|_| |
| 804 | err!("Invalid hour value in timezone: {}", tz; Invalid, Input))); |
| 805 | |
| 806 | Ok(sign * hours * 3600) |
| 807 | } else { |
| 808 | Err(err!("Unsupported timezone format: {}", tz; Invalid, Input)) |
| 809 | } |
| 810 | } |
| 811 | } |
| 812 | |
| 813 | #[derive(Debug)] |
| 814 | struct SimpleDate { |
| 815 | year: i32, |
| 816 | } |
| 817 | |
| 818 | impl PartialEq for CalClockZone { |
| 819 | fn eq(&self, other: &Self) -> bool { |
| 820 | self.id == other.id && self.zone_data == other.zone_data |
| 821 | // Note: We exclude tzif_data from equality comparison for performance |
| 822 | // as the same timezone can be represented with different TZif data |
| 823 | } |
| 824 | } |
| 825 | |
| 826 | impl Eq for CalClockZone {} |
| 827 | |
| 828 | impl std::hash::Hash for CalClockZone { |
| 829 | fn hash<H: std::hash::Hasher>(&self, state: &mut H) { |
| 830 | self.id.hash(state); |
| 831 | self.zone_data.hash(state); |
| 832 | // Note: We exclude tzif_data from hash for performance and consistency |
| 833 | } |
| 834 | } |
| 835 | |
| 836 | impl Default for CalClockZone { |
| 837 | fn default() -> Self { |
| 838 | Self::utc() |
| 839 | } |
| 840 | } |
| 841 | |
| 842 | impl Display for CalClockZone { |
| 843 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 844 | write!(f, "{}", self.id) |
| 845 | } |
| 846 | } |
| 847 | |
| 848 | #[cfg(test)] |
| 849 | mod tests { |
| 850 | use super::*; |
| 851 | |
| 852 | #[test] |
| 853 | fn test_utc_creation() { |
| 854 | let utc = CalClockZone::utc(); |
| 855 | assert_eq!(utc.id(), "UTC"); |
| 856 | assert_eq!(utc.raw_offset_millis(), 0); |
| 857 | } |
| 858 | |
| 859 | #[test] |
| 860 | fn test_fixed_offset_parsing() -> Outcome<()> { |
| 861 | let gmt_plus_5 = res!(CalClockZone::new("GMT+5")); |
| 862 | assert_eq!(gmt_plus_5.raw_offset_millis(), 5 * 3600 * 1000); |
| 863 | |
| 864 | let gmt_minus_3 = res!(CalClockZone::new("GMT-3")); |
| 865 | assert_eq!(gmt_minus_3.raw_offset_millis(), -3 * 3600 * 1000); |
| 866 | Ok(()) |
| 867 | } |
| 868 | |
| 869 | #[test] |
| 870 | fn test_timezone_database_lookup() -> Outcome<()> { |
| 871 | let eastern = res!(CalClockZone::new("America/New_York")); |
| 872 | assert_eq!(eastern.id(), "America/New_York"); |
| 873 | assert_eq!(eastern.raw_offset_millis(), -5 * 3600 * 1000); |
| 874 | Ok(()) |
| 875 | } |
| 876 | |
| 877 | #[test] |
| 878 | fn test_offset_compatibility() -> Outcome<()> { |
| 879 | let utc = CalClockZone::utc(); |
| 880 | assert_eq!(res!(utc.offset_seconds(1640995200)), 0); // 2022-01-01 UTC |
| 881 | Ok(()) |
| 882 | } |
| 883 | |
| 884 | #[test] |
| 885 | fn test_dst_detection() -> Outcome<()> { |
| 886 | let eastern = res!(CalClockZone::new("America/New_York")); |
| 887 | // This would need proper date calculation in full implementation |
| 888 | assert!(!res!(eastern.in_daylight_time(0))); // Simplified test |
| 889 | Ok(()) |
| 890 | } |
| 891 | } |