Skip to main content

seedfaker_core/
temporal.rs

1//! Temporal value parsing: year, date, datetime, epoch seconds.
2//!
3//! All temporal values are represented as Unix epoch seconds (`i64`).
4//! Supported input formats: `2025`, `2025-03-28`, `2025-03-28T14:00`,
5//! `2025-03-28T14:00:30`, or raw epoch seconds (`1711630800`).
6
7/// Epoch seconds for `1900-01-01T00:00:00Z`.
8pub const DEFAULT_SINCE: i64 = -2_208_988_800;
9
10/// Convert calendar components to Unix epoch seconds (UTC).
11/// Uses the proleptic Gregorian calendar. No leap-second handling.
12pub fn date_to_epoch(y: i64, m: i64, d: i64, h: i64, min: i64, s: i64) -> i64 {
13    // Howard Hinnant's civil_from_days algorithm
14    let (mut yr, mut mo) = (y, m);
15    if mo <= 2 {
16        yr -= 1;
17        mo += 9;
18    } else {
19        mo -= 3;
20    }
21    let era = if yr >= 0 { yr } else { yr - 399 } / 400;
22    let yoe = yr - era * 400;
23    let doy = (153 * mo + 2) / 5 + d - 1;
24    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
25    let days = era * 146_097 + doe - 719_468;
26    days * 86_400 + h * 3600 + min * 60 + s
27}
28
29/// Compute the default `--until` value: start of next year in epoch seconds.
30/// Current Unix timestamp — default `--until`. No future dates.
31pub fn default_until() -> i64 {
32    let secs = std::time::SystemTime::now()
33        .duration_since(std::time::UNIX_EPOCH)
34        .unwrap_or_default()
35        .as_secs();
36    i64::try_from(secs).unwrap_or(i64::MAX)
37}
38
39/// Extract the year from an epoch value.
40///
41/// Inverse of `date_to_epoch(year, 1, 1, 0, 0, 0)`:
42/// `epoch_to_year(date_to_epoch(y, 1, 1, 0, 0, 0)) == y` for any valid year.
43pub fn epoch_to_year(epoch: i64) -> i64 {
44    // Approximate, then correct by checking boundary
45    let approx = 1970 + epoch / 31_557_600;
46    if date_to_epoch(approx + 1, 1, 1, 0, 0, 0) <= epoch {
47        approx + 1
48    } else if date_to_epoch(approx, 1, 1, 0, 0, 0) > epoch {
49        approx - 1
50    } else {
51        approx
52    }
53}
54
55/// Parse a temporal string into epoch seconds.
56///
57/// Accepts: year (`2025`), date (`2025-03-28`), datetime (`2025-03-28T14:00`
58/// or `2025-03-28T14:00:30`), or raw epoch seconds (`1711630800`).
59pub fn parse(s: &str) -> Result<i64, String> {
60    let s = s.trim();
61
62    // Try raw integer first
63    if let Ok(v) = s.parse::<i64>() {
64        if v > 100_000 {
65            // Epoch seconds
66            return Ok(v);
67        }
68        if (1..=9999).contains(&v) {
69            // Year
70            return Ok(date_to_epoch(v, 1, 1, 0, 0, 0));
71        }
72        return Err(format!(
73            "ambiguous temporal value: {v}; use a year (1-9999) or epoch seconds (>100000)"
74        ));
75    }
76
77    // YYYY-MM-DD or YYYY-MM-DDTHH:MM or YYYY-MM-DDTHH:MM:SS
78    let parts: Vec<&str> = s.splitn(2, 'T').collect();
79    let date_part = parts[0];
80    let time_part = if parts.len() > 1 { parts[1] } else { "" };
81
82    let date_segs: Vec<&str> = date_part.split('-').collect();
83    if date_segs.len() != 3 {
84        return Err(format!(
85            "invalid temporal format '{s}'; expected: YYYY, YYYY-MM-DD, YYYY-MM-DDTHH:MM, YYYY-MM-DDTHH:MM:SS, or epoch seconds"
86        ));
87    }
88
89    let y = date_segs[0].parse::<i64>().map_err(|_| format!("invalid year in '{s}'"))?;
90    let m = date_segs[1].parse::<i64>().map_err(|_| format!("invalid month in '{s}'"))?;
91    let d = date_segs[2].parse::<i64>().map_err(|_| format!("invalid day in '{s}'"))?;
92
93    if !(1..=12).contains(&m) {
94        return Err(format!("month out of range in '{s}'"));
95    }
96    if !(1..=31).contains(&d) {
97        return Err(format!("day out of range in '{s}'"));
98    }
99
100    if time_part.is_empty() {
101        return Ok(date_to_epoch(y, m, d, 0, 0, 0));
102    }
103
104    let time_segs: Vec<&str> = time_part.split(':').collect();
105    let h = time_segs
106        .first()
107        .and_then(|s| s.parse::<i64>().ok())
108        .ok_or_else(|| format!("invalid hour in '{s}'"))?;
109    let min = time_segs
110        .get(1)
111        .and_then(|s| s.parse::<i64>().ok())
112        .ok_or_else(|| format!("invalid minute in '{s}'"))?;
113    // Seconds are optional in HH:MM format (ISO 8601); default to 0.
114    let sec = match time_segs.get(2) {
115        Some(s) => s.parse::<i64>().map_err(|_| format!("invalid seconds in '{s}'"))?,
116        None => 0,
117    };
118
119    if !(0..=23).contains(&h) {
120        return Err(format!("hour out of range in '{s}'"));
121    }
122    if !(0..=59).contains(&min) {
123        return Err(format!("minute out of range in '{s}'"));
124    }
125
126    Ok(date_to_epoch(y, m, d, h, min, sec))
127}
128
129/// Parse a temporal string for use as an upper bound ("until").
130///
131/// Year → end of year (start of next). Date → end of day (start of next).
132/// Datetime and epoch → same as `parse()`.
133pub fn parse_until(s: &str) -> Result<i64, String> {
134    let s = s.trim();
135    if let Ok(v) = s.parse::<i64>() {
136        if v > 100_000 {
137            return Ok(v);
138        }
139        if (1..=9999).contains(&v) {
140            return Ok(date_to_epoch(v + 1, 1, 1, 0, 0, 0));
141        }
142    }
143    // Date without time → end of day
144    if !s.contains('T') && s.contains('-') {
145        let e = parse(s)?;
146        return Ok(e + 86_400);
147    }
148    parse(s)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn parse_year() {
157        let e = parse("2025").unwrap();
158        assert_eq!(epoch_to_year(e), 2025);
159    }
160
161    #[test]
162    fn parse_date() {
163        let e = parse("2025-01-01").unwrap();
164        assert_eq!(e, date_to_epoch(2025, 1, 1, 0, 0, 0));
165    }
166
167    #[test]
168    fn parse_datetime() {
169        let e = parse("2025-03-28T14:00").unwrap();
170        assert_eq!(e, date_to_epoch(2025, 3, 28, 14, 0, 0));
171    }
172
173    #[test]
174    fn parse_datetime_seconds() {
175        let e = parse("2025-03-28T14:00:30").unwrap();
176        assert_eq!(e, date_to_epoch(2025, 3, 28, 14, 0, 30));
177    }
178
179    #[test]
180    fn parse_epoch() {
181        assert_eq!(parse("1711630800").unwrap(), 1_711_630_800);
182    }
183
184    #[test]
185    fn parse_invalid() {
186        assert!(parse("abc").is_err());
187        assert!(parse("2025-13-01").is_err());
188        assert!(parse("2025-01-32").is_err());
189    }
190
191    #[test]
192    fn default_since_is_1900() {
193        assert_eq!(epoch_to_year(DEFAULT_SINCE), 1900);
194    }
195
196    // --- parse_until edge cases ---
197
198    #[test]
199    fn until_year_is_exclusive() {
200        let e = parse_until("2025").unwrap();
201        assert_eq!(e, date_to_epoch(2026, 1, 1, 0, 0, 0));
202        assert_eq!(epoch_to_year(e - 1), 2025);
203    }
204
205    #[test]
206    fn until_date_is_end_of_day() {
207        let e = parse_until("2025-03-28").unwrap();
208        let start = parse("2025-03-28").unwrap();
209        assert_eq!(e, start + 86_400);
210    }
211
212    #[test]
213    fn until_datetime_is_exact() {
214        let e = parse_until("2025-03-28T16:00").unwrap();
215        assert_eq!(e, date_to_epoch(2025, 3, 28, 16, 0, 0));
216    }
217
218    #[test]
219    fn until_epoch_is_exact() {
220        assert_eq!(parse_until("1711638000").unwrap(), 1_711_638_000);
221    }
222
223    // --- roundtrip edge cases ---
224
225    #[test]
226    fn roundtrip_year_boundaries() {
227        for y in [1900, 1970, 1999, 2000, 2001, 2024, 2025, 2038, 2100] {
228            let e = parse(&y.to_string()).unwrap();
229            assert_eq!(epoch_to_year(e), y, "roundtrip failed for year {y}");
230        }
231    }
232
233    #[test]
234    fn roundtrip_dates() {
235        let cases = [
236            ("2025-01-01", 2025, 1, 1),
237            ("2000-02-29", 2000, 2, 29),  // leap year
238            ("1970-01-01", 1970, 1, 1),   // epoch zero
239            ("2025-12-31", 2025, 12, 31), // end of year
240        ];
241        for (input, y, m, d) in cases {
242            let e = parse(input).unwrap();
243            assert_eq!(e, date_to_epoch(y, m, d, 0, 0, 0), "parse failed for {input}");
244        }
245    }
246
247    #[test]
248    fn midnight_vs_2359() {
249        let midnight = parse("2025-03-28T00:00").unwrap();
250        let eod = parse("2025-03-28T23:59").unwrap();
251        assert_eq!(eod - midnight, 23 * 3600 + 59 * 60);
252    }
253
254    #[test]
255    fn whitespace_trimmed() {
256        assert_eq!(parse("  2025  ").unwrap(), parse("2025").unwrap());
257        assert_eq!(parse(" 2025-03-28 ").unwrap(), parse("2025-03-28").unwrap());
258    }
259
260    #[test]
261    fn invalid_time_components() {
262        assert!(parse("2025-01-01T25:00").is_err()); // hour 25
263        assert!(parse("2025-01-01T12:60").is_err()); // minute 60
264    }
265
266    #[test]
267    fn ambiguous_small_number() {
268        assert!(parse("50000").is_err()); // between year and epoch — ambiguous
269    }
270}