vibesql_types/temporal/
timestamp.rs1use std::{cmp::Ordering, fmt, str::FromStr};
4
5use super::{Date, Time};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct Timestamp {
18 pub date: Date,
19 pub time: Time,
20}
21
22impl Timestamp {
23 pub fn new(date: Date, time: Time) -> Self {
25 Timestamp { date, time }
26 }
27}
28
29impl FromStr for Timestamp {
30 type Err = String;
31
32 fn from_str(s: &str) -> Result<Self, Self::Err> {
33 let trimmed = s.trim();
35
36 let timestamp_part = strip_timezone_suffix(trimmed);
38
39 if let Some(t_pos) = timestamp_part.find('T') {
41 let date_str = ×tamp_part[..t_pos];
42 let time_str = ×tamp_part[t_pos + 1..];
43
44 let date = Date::from_str(date_str)?;
45 let time = Time::from_str(time_str)?;
46
47 return Ok(Timestamp::new(date, time));
48 }
49
50 let parts: Vec<&str> = timestamp_part.split_whitespace().collect();
52
53 if parts.len() == 2 {
54 let date = Date::from_str(parts[0])?;
56 let time = Time::from_str(parts[1])?;
57
58 return Ok(Timestamp::new(date, time));
59 } else if parts.len() == 1 {
60 if let Ok(date) = Date::from_str(parts[0]) {
62 let midnight = Time::new(0, 0, 0, 0).unwrap();
64 return Ok(Timestamp::new(date, midnight));
65 }
66 }
67
68 Err(format!(
70 "Invalid timestamp format: '{}'. Supported formats: \
71 ISO 8601 (2025-11-10T08:24:34), \
72 space-separated (2025-11-10 08:24:34), \
73 or date only (2025-11-10)",
74 s
75 ))
76 }
77}
78
79fn strip_timezone_suffix(s: &str) -> &str {
82 if s.ends_with('Z') || s.ends_with('z') {
84 return &s[..s.len() - 1];
85 }
86
87 if let Some(pos) = s.rfind(['+', '-']) {
90 if pos > 10 {
93 let potential_tz = &s[pos..];
95 if is_timezone_offset(potential_tz) {
96 return &s[..pos];
97 }
98 }
99 }
100
101 s
102}
103
104fn is_timezone_offset(s: &str) -> bool {
106 if s.len() < 3 {
107 return false;
108 }
109
110 let sign = s.chars().next().unwrap();
111 if sign != '+' && sign != '-' {
112 return false;
113 }
114
115 let rest = &s[1..];
116
117 if rest.len() == 5 && rest.chars().nth(2) == Some(':') {
119 return rest[..2].chars().all(|c| c.is_ascii_digit())
120 && rest[3..].chars().all(|c| c.is_ascii_digit());
121 }
122
123 if rest.len() == 4 {
125 return rest.chars().all(|c| c.is_ascii_digit());
126 }
127
128 if rest.len() == 2 {
130 return rest.chars().all(|c| c.is_ascii_digit());
131 }
132
133 false
134}
135
136impl fmt::Display for Timestamp {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 write!(f, "{} {}", self.date, self.time)
139 }
140}
141
142impl PartialOrd for Timestamp {
143 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
144 Some(self.cmp(other))
145 }
146}
147
148impl Ord for Timestamp {
149 fn cmp(&self, other: &Self) -> Ordering {
150 self.date.cmp(&other.date).then_with(|| self.time.cmp(&other.time))
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn display_pads_fraction_to_minimum_three_digits() {
160 let ts = Timestamp::from_str("2024-01-01 12:00:00.5").unwrap();
163 assert_eq!(ts.to_string(), "2024-01-01 12:00:00.500");
164 }
165
166 #[test]
167 fn display_preserves_sub_millisecond_digits() {
168 let ts = Timestamp::from_str("2024-01-01 14:30:00.123456").unwrap();
169 assert_eq!(ts.to_string(), "2024-01-01 14:30:00.123456");
170 }
171
172 #[test]
173 fn display_parse_round_trip_preserves_value() {
174 for s in [
175 "2024-01-01 12:00:00",
176 "2024-01-01 12:00:00.5",
177 "2024-01-01 12:00:00.500",
178 "2024-01-01 14:30:00.123456",
179 "2024-01-01 14:30:00.123456789",
180 ] {
181 let ts = Timestamp::from_str(s).unwrap();
182 let reparsed = Timestamp::from_str(&ts.to_string()).unwrap();
183 assert_eq!(reparsed, ts, "round-trip failed for '{}' (rendered '{}')", s, ts);
184 }
185 }
186}