Oregami
Repositories/oxedyne/fe2o3

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
4use crate::{
5 time::tzif::{TZifData, TZifParser, LocalTimeResult},
6};
7
8use oxedyne_fe2o3_core::prelude::*;
9
10use 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)]
35pub struct CalClockZone {
36 id: String,
37 zone_data: TimezoneData,
38 tzif_data: Option<TZifData>,
39}
40
41#[derive(Clone, Debug, Eq, Hash, PartialEq)]
42enum 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)]
54struct 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)]
63struct DstTransition {
64 month: u8, // 1-12
65 day_spec: DaySpec,
66 hour: u8, // 0-23
67}
68
69#[derive(Clone, Debug, Eq, Hash, PartialEq)]
70enum 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
77static TIMEZONE_DB: OnceLock<HashMap<String, TimezoneData>> = OnceLock::new();
78
79fn 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
164fn get_timezone_db() -> &'static HashMap<String, TimezoneData> {
165 TIMEZONE_DB.get_or_init(init_timezone_db)
166}
167
168impl 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)]
814struct SimpleDate {
815 year: i32,
816}
817
818impl 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
826impl Eq for CalClockZone {}
827
828impl 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
836impl Default for CalClockZone {
837 fn default() -> Self {
838 Self::utc()
839 }
840}
841
842impl Display for CalClockZone {
843 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
844 write!(f, "{}", self.id)
845 }
846}
847
848#[cfg(test)]
849mod 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}