Skip to main content

kernex_memory/
types.rs

1//! Typed row shapes returned by the `MemoryStore` trait.
2//!
3//! Replaces the prior `(String, String, String)` tuples on
4//! `search_messages` and the `(String, String)` tuples on `get_history`
5//! so consumers never see raw SQLite timestamp strings. `timestamp` and
6//! `updated_at` are parsed at fetch time into `SystemTime`; consumers
7//! compare and format from there.
8
9use std::time::SystemTime;
10
11use crate::error::MemoryError;
12
13/// A single message row, returned by `search_messages` and
14/// `get_message_by_id`. `timestamp` is parsed from the SQLite TIMESTAMP
15/// column at fetch time so consumers compare against `SystemTime`
16/// directly instead of parsing a string.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct MessageRow {
19    /// Stable UUID identifying the message row.
20    pub id: String,
21    /// Identifier of the conversation the message belongs to.
22    pub conversation_id: String,
23    /// `"user"` or `"assistant"`.
24    pub role: String,
25    /// Full message body text.
26    pub content: String,
27    /// Wall-clock time the message was stored, in UTC.
28    pub timestamp: SystemTime,
29}
30
31/// One closed-conversation summary row returned by `get_history`.
32/// `updated_at` is parsed from SQLite at fetch time.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct HistoryRow {
35    /// Identifier of the conversation.
36    pub conversation_id: String,
37    /// Conversation summary text; `"(no summary)"` when the column is
38    /// NULL, matching the inherent-method fallback shipped before
39    /// Slice B.
40    pub summary: String,
41    /// Wall-clock time the conversation was last updated, in UTC.
42    pub updated_at: SystemTime,
43}
44
45/// Parse a SQLite `TIMESTAMP` string ("YYYY-MM-DD HH:MM:SS", UTC) into a
46/// `SystemTime`. Returns `MemoryError::Logic` on shape mismatch so
47/// callers can distinguish a parse failure from a database error.
48///
49/// The schema's `timestamp` and `updated_at` columns default to
50/// `datetime('now')`, which always emits the 19-character format above.
51/// A failure here means the column was hand-edited or the schema
52/// drifted; in either case it is a logic/data error, not a SQLite error.
53pub(crate) fn parse_sqlite_timestamp(s: &str) -> Result<SystemTime, MemoryError> {
54    let naive = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
55        .map_err(|e| MemoryError::logic(format!("parse timestamp {s:?}: {e}")))?;
56    Ok(naive.and_utc().into())
57}
58
59/// Format a `SystemTime` back to the SQLite `TIMESTAMP` shape
60/// ("YYYY-MM-DD HH:MM:SS", UTC). Used by `search_messages` when binding
61/// the `since` parameter to the prepared statement.
62pub(crate) fn format_sqlite_timestamp(t: SystemTime) -> String {
63    let dt: chrono::DateTime<chrono::Utc> = t.into();
64    dt.format("%Y-%m-%d %H:%M:%S").to_string()
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use std::time::Duration;
71
72    #[test]
73    fn parse_roundtrips_through_format() {
74        let original = SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_000);
75        let formatted = format_sqlite_timestamp(original);
76        let parsed = parse_sqlite_timestamp(&formatted).unwrap();
77        assert_eq!(parsed, original);
78    }
79
80    #[test]
81    fn parse_rejects_garbage() {
82        let err = parse_sqlite_timestamp("not a timestamp").unwrap_err();
83        assert!(matches!(err, MemoryError::Logic(_)));
84    }
85
86    #[test]
87    fn parse_accepts_schema_default_shape() {
88        // datetime('now') emits "YYYY-MM-DD HH:MM:SS" without timezone.
89        let parsed = parse_sqlite_timestamp("2026-05-11 18:00:00").unwrap();
90        let formatted = format_sqlite_timestamp(parsed);
91        assert_eq!(formatted, "2026-05-11 18:00:00");
92    }
93}