Skip to main content

spatial_narrative/core/
timestamp.rs

1//! Timestamp representation with precision awareness.
2
3use crate::error::{Error, Result};
4use chrono::{DateTime, TimeZone, Utc};
5use serde::{Deserialize, Serialize};
6
7/// Precision level for timestamps.
8///
9/// Real-world data often has varying levels of temporal precision.
10/// This enum captures how precise a timestamp is.
11#[derive(
12    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
13)]
14#[serde(rename_all = "lowercase")]
15pub enum TemporalPrecision {
16    /// Year only (e.g., "2024")
17    Year,
18    /// Year and month (e.g., "2024-03")
19    Month,
20    /// Year, month, and day (e.g., "2024-03-15")
21    Day,
22    /// Down to the hour
23    Hour,
24    /// Down to the minute
25    Minute,
26    /// Down to the second
27    #[default]
28    Second,
29    /// Sub-second precision (milliseconds)
30    Millisecond,
31}
32
33/// A timestamp with timezone awareness and precision tracking.
34///
35/// Timestamps in spatial narratives often come from sources with varying
36/// precision (e.g., "sometime in March 2024" vs "2024-03-15T14:30:00Z").
37///
38/// # Examples
39///
40/// ```
41/// use spatial_narrative::core::{Timestamp, TemporalPrecision};
42///
43/// // Current time
44/// let now = Timestamp::now();
45///
46/// // Parse from ISO 8601
47/// let ts = Timestamp::parse("2024-03-15T14:30:00Z").unwrap();
48///
49/// // With explicit precision
50/// let approximate = Timestamp::with_precision(
51///     Timestamp::parse("2024-03-01T00:00:00Z").unwrap().datetime,
52///     TemporalPrecision::Month
53/// );
54/// ```
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Timestamp {
57    /// The datetime in UTC.
58    pub datetime: DateTime<Utc>,
59    /// The precision of this timestamp.
60    #[serde(default)]
61    pub precision: TemporalPrecision,
62}
63
64impl Timestamp {
65    /// Creates a new timestamp with the given datetime and default (Second) precision.
66    pub fn new(datetime: DateTime<Utc>) -> Self {
67        Self {
68            datetime,
69            precision: TemporalPrecision::Second,
70        }
71    }
72
73    /// Creates a new timestamp with explicit precision.
74    pub fn with_precision(datetime: DateTime<Utc>, precision: TemporalPrecision) -> Self {
75        Self {
76            datetime,
77            precision,
78        }
79    }
80
81    /// Creates a timestamp for the current moment.
82    pub fn now() -> Self {
83        Self::new(Utc::now())
84    }
85
86    /// Parses a timestamp from an ISO 8601 string.
87    ///
88    /// Supported formats:
89    /// - `2024-03-15T14:30:00Z` (full precision)
90    /// - `2024-03-15T14:30:00+00:00` (with timezone offset)
91    /// - `2024-03-15` (date only, day precision)
92    /// - `2024-03` (year-month, month precision)
93    /// - `2024` (year only, year precision)
94    pub fn parse(s: &str) -> Result<Self> {
95        // Try full ISO 8601 with timezone
96        if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
97            return Ok(Self::new(dt.with_timezone(&Utc)));
98        }
99
100        // Try full ISO 8601 with Z suffix
101        if let Ok(dt) = s.parse::<DateTime<Utc>>() {
102            return Ok(Self::new(dt));
103        }
104
105        // Try date only (YYYY-MM-DD)
106        if s.len() == 10 {
107            if let Ok(naive) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
108                let dt = naive
109                    .and_hms_opt(0, 0, 0)
110                    .map(|ndt| Utc.from_utc_datetime(&ndt))
111                    .ok_or_else(|| Error::InvalidTimestamp(s.to_string()))?;
112                return Ok(Self::with_precision(dt, TemporalPrecision::Day));
113            }
114        }
115
116        // Try year-month (YYYY-MM)
117        if s.len() == 7 && s.chars().nth(4) == Some('-') {
118            let year: i32 = s[0..4]
119                .parse()
120                .map_err(|_| Error::InvalidTimestamp(s.to_string()))?;
121            let month: u32 = s[5..7]
122                .parse()
123                .map_err(|_| Error::InvalidTimestamp(s.to_string()))?;
124
125            if let Some(naive) = chrono::NaiveDate::from_ymd_opt(year, month, 1) {
126                let dt = naive
127                    .and_hms_opt(0, 0, 0)
128                    .map(|ndt| Utc.from_utc_datetime(&ndt))
129                    .ok_or_else(|| Error::InvalidTimestamp(s.to_string()))?;
130                return Ok(Self::with_precision(dt, TemporalPrecision::Month));
131            }
132        }
133
134        // Try year only (YYYY)
135        if s.len() == 4 {
136            let year: i32 = s
137                .parse()
138                .map_err(|_| Error::InvalidTimestamp(s.to_string()))?;
139
140            if let Some(naive) = chrono::NaiveDate::from_ymd_opt(year, 1, 1) {
141                let dt = naive
142                    .and_hms_opt(0, 0, 0)
143                    .map(|ndt| Utc.from_utc_datetime(&ndt))
144                    .ok_or_else(|| Error::InvalidTimestamp(s.to_string()))?;
145                return Ok(Self::with_precision(dt, TemporalPrecision::Year));
146            }
147        }
148
149        Err(Error::InvalidTimestamp(s.to_string()))
150    }
151
152    /// Creates a timestamp from Unix epoch seconds.
153    pub fn from_unix(secs: i64) -> Option<Self> {
154        DateTime::from_timestamp(secs, 0).map(Self::new)
155    }
156
157    /// Creates a timestamp from Unix epoch milliseconds.
158    pub fn from_unix_millis(millis: i64) -> Option<Self> {
159        DateTime::from_timestamp_millis(millis)
160            .map(|dt| Self::with_precision(dt, TemporalPrecision::Millisecond))
161    }
162
163    /// Returns the Unix timestamp in seconds.
164    pub fn unix_timestamp(&self) -> i64 {
165        self.datetime.timestamp()
166    }
167
168    /// Returns the Unix timestamp in milliseconds.
169    pub fn unix_timestamp_millis(&self) -> i64 {
170        self.datetime.timestamp_millis()
171    }
172
173    /// Returns the Unix timestamp in milliseconds (alias for `unix_timestamp_millis`).
174    pub fn to_unix_millis(&self) -> i64 {
175        self.datetime.timestamp_millis()
176    }
177
178    /// Formats the timestamp as an ISO 8601 string.
179    pub fn to_rfc3339(&self) -> String {
180        self.datetime.to_rfc3339()
181    }
182
183    /// Formats the timestamp according to precision.
184    pub fn format_with_precision(&self) -> String {
185        match self.precision {
186            TemporalPrecision::Year => self.datetime.format("%Y").to_string(),
187            TemporalPrecision::Month => self.datetime.format("%Y-%m").to_string(),
188            TemporalPrecision::Day => self.datetime.format("%Y-%m-%d").to_string(),
189            TemporalPrecision::Hour => self.datetime.format("%Y-%m-%dT%H:00:00Z").to_string(),
190            TemporalPrecision::Minute => self.datetime.format("%Y-%m-%dT%H:%M:00Z").to_string(),
191            TemporalPrecision::Second | TemporalPrecision::Millisecond => {
192                self.datetime.to_rfc3339()
193            },
194        }
195    }
196
197    /// Checks if this timestamp is before another.
198    pub fn is_before(&self, other: &Timestamp) -> bool {
199        self.datetime < other.datetime
200    }
201
202    /// Checks if this timestamp is after another.
203    pub fn is_after(&self, other: &Timestamp) -> bool {
204        self.datetime > other.datetime
205    }
206
207    /// Returns the duration between this timestamp and another.
208    pub fn duration_since(&self, earlier: &Timestamp) -> chrono::Duration {
209        self.datetime.signed_duration_since(earlier.datetime)
210    }
211}
212
213impl Default for Timestamp {
214    fn default() -> Self {
215        Self::now()
216    }
217}
218
219impl PartialOrd for Timestamp {
220    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
221        Some(self.cmp(other))
222    }
223}
224
225impl Ord for Timestamp {
226    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
227        self.datetime.cmp(&other.datetime)
228    }
229}
230
231impl std::hash::Hash for Timestamp {
232    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
233        self.datetime.hash(state);
234        self.precision.hash(state);
235    }
236}
237
238impl std::fmt::Display for Timestamp {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        write!(f, "{}", self.format_with_precision())
241    }
242}
243
244impl From<DateTime<Utc>> for Timestamp {
245    fn from(datetime: DateTime<Utc>) -> Self {
246        Self::new(datetime)
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_timestamp_now() {
256        let ts = Timestamp::now();
257        assert_eq!(ts.precision, TemporalPrecision::Second);
258    }
259
260    #[test]
261    fn test_timestamp_parse_rfc3339() {
262        let ts = Timestamp::parse("2024-03-15T14:30:00Z").unwrap();
263        assert_eq!(ts.datetime.year(), 2024);
264        assert_eq!(ts.datetime.month(), 3);
265        assert_eq!(ts.datetime.day(), 15);
266    }
267
268    #[test]
269    fn test_timestamp_parse_date_only() {
270        let ts = Timestamp::parse("2024-03-15").unwrap();
271        assert_eq!(ts.precision, TemporalPrecision::Day);
272        assert_eq!(ts.datetime.year(), 2024);
273        assert_eq!(ts.datetime.month(), 3);
274        assert_eq!(ts.datetime.day(), 15);
275    }
276
277    #[test]
278    fn test_timestamp_parse_year_month() {
279        let ts = Timestamp::parse("2024-03").unwrap();
280        assert_eq!(ts.precision, TemporalPrecision::Month);
281        assert_eq!(ts.datetime.year(), 2024);
282        assert_eq!(ts.datetime.month(), 3);
283    }
284
285    #[test]
286    fn test_timestamp_parse_year() {
287        let ts = Timestamp::parse("2024").unwrap();
288        assert_eq!(ts.precision, TemporalPrecision::Year);
289        assert_eq!(ts.datetime.year(), 2024);
290    }
291
292    #[test]
293    fn test_timestamp_parse_invalid() {
294        assert!(Timestamp::parse("not a timestamp").is_err());
295        assert!(Timestamp::parse("").is_err());
296    }
297
298    #[test]
299    fn test_timestamp_from_unix() {
300        let ts = Timestamp::from_unix(1710510600).unwrap(); // 2024-03-15T14:30:00Z
301        assert_eq!(ts.datetime.year(), 2024);
302    }
303
304    #[test]
305    fn test_timestamp_format_with_precision() {
306        let ts = Timestamp::parse("2024-03").unwrap();
307        assert_eq!(ts.format_with_precision(), "2024-03");
308    }
309
310    #[test]
311    fn test_timestamp_ordering() {
312        let ts1 = Timestamp::parse("2024-01-01T00:00:00Z").unwrap();
313        let ts2 = Timestamp::parse("2024-06-01T00:00:00Z").unwrap();
314        assert!(ts1 < ts2);
315        assert!(ts1.is_before(&ts2));
316        assert!(ts2.is_after(&ts1));
317    }
318
319    #[test]
320    fn test_timestamp_duration() {
321        let ts1 = Timestamp::parse("2024-01-01T00:00:00Z").unwrap();
322        let ts2 = Timestamp::parse("2024-01-02T00:00:00Z").unwrap();
323        let duration = ts2.duration_since(&ts1);
324        assert_eq!(duration.num_days(), 1);
325    }
326
327    #[test]
328    fn test_timestamp_serialization() {
329        let ts = Timestamp::parse("2024-03-15T14:30:00Z").unwrap();
330        let json = serde_json::to_string(&ts).unwrap();
331        let parsed: Timestamp = serde_json::from_str(&json).unwrap();
332        assert_eq!(ts.datetime, parsed.datetime);
333    }
334
335    use chrono::Datelike;
336}