1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Shared `SQLite` `datetime('now')` TEXT-timestamp parsing.
//!
//! Used by [`crate::graph::types`], [`crate::graph::activation`], and [`crate::eviction`] to
//! convert `SQLite` TEXT timestamps into Unix seconds without pulling in `chrono`.
/// Parse a `SQLite` `datetime('now')` string to Unix seconds.
///
/// Accepts:
/// - `"YYYY-MM-DD HH:MM:SS"` (19 chars, standard `SQLite` format)
/// - `"YYYY-MM-DD HH:MM:SS.fff"` (fractional seconds — truncated, not rounded)
/// - `"YYYY-MM-DD HH:MM:SSZ"` or `"YYYY-MM-DD HH:MM:SS+HH:MM"` (timezone suffix — treated as UTC)
///
/// Leading/trailing whitespace is trimmed before parsing.
///
/// Returns `None` if the string cannot be parsed.
#[must_use]
pub(crate) fn parse_sqlite_datetime_to_unix(s: &str) -> Option<i64> {
let s = s.trim();
// Minimum: "YYYY-MM-DD HH:MM:SS" (19 chars)
if s.len() < 19 {
return None;
}
let year: i64 = s[0..4].parse().ok()?;
let month: i64 = s[5..7].parse().ok()?;
let day: i64 = s[8..10].parse().ok()?;
let hour: i64 = s[11..13].parse().ok()?;
let min: i64 = s[14..16].parse().ok()?;
// Only parse the base seconds; ignore fractional seconds and timezone suffix.
let sec: i64 = s[17..19].parse().ok()?;
// Days since Unix epoch (1970-01-01) via civil calendar algorithm.
// Reference: https://howardhinnant.github.io/date_algorithms.html#days_from_civil
let (y, m) = if month <= 2 {
(year - 1, month + 9)
} else {
(year, month - 3)
};
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * m + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(days * 86_400 + hour * 3_600 + min * 60 + sec)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_epoch() {
assert_eq!(
parse_sqlite_datetime_to_unix("1970-01-01 00:00:00"),
Some(0)
);
assert_eq!(
parse_sqlite_datetime_to_unix("1970-01-02 00:00:00"),
Some(86_400)
);
}
#[test]
fn known_value() {
// 2024-01-01 00:00:00 UTC = 1704067200
assert_eq!(
parse_sqlite_datetime_to_unix("2024-01-01 00:00:00"),
Some(1_704_067_200)
);
}
#[test]
fn invalid_returns_none() {
assert_eq!(parse_sqlite_datetime_to_unix("not-a-date"), None);
assert_eq!(parse_sqlite_datetime_to_unix(""), None);
}
#[test]
fn pre_1970_produces_negative_value_not_none() {
// A syntactically valid but pre-epoch date parses successfully to a negative
// Unix timestamp — it is not treated as an error. Callers that need a `u64` (e.g.
// `eviction::parse_sqlite_timestamp_secs`) must convert and handle the negative
// case explicitly; they must not assume this function only ever returns `None` for
// "bad" input.
assert_eq!(
parse_sqlite_datetime_to_unix("1969-12-31 23:59:59"),
Some(-1)
);
assert!(parse_sqlite_datetime_to_unix("1900-01-01 00:00:00").unwrap() < 0);
}
#[test]
fn fractional_seconds_truncated() {
assert_eq!(
parse_sqlite_datetime_to_unix("1970-01-01 00:00:00.999"),
Some(0)
);
assert_eq!(
parse_sqlite_datetime_to_unix("1970-01-02 00:00:00.123"),
Some(86_400)
);
}
#[test]
fn timezone_suffix_treated_as_utc() {
assert_eq!(
parse_sqlite_datetime_to_unix("1970-01-01 00:00:00Z"),
Some(0)
);
assert_eq!(
parse_sqlite_datetime_to_unix("1970-01-01 00:00:00+05:30"),
Some(0)
);
}
#[test]
fn whitespace_is_trimmed() {
assert_eq!(
parse_sqlite_datetime_to_unix(" 1970-01-01 00:00:00 "),
Some(0)
);
}
}