Skip to main content

zeph_memory/store/
channel_preferences.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Per-channel UX preference storage (#3308).
5//!
6//! Persists and restores lightweight per-channel settings (currently: active provider name)
7//! across agent restarts. The identity key is a composite `(channel_type, channel_id)` pair
8//! to support both single-user channels (CLI/TUI) and multi-tenant channels (Telegram, Discord).
9//!
10//! # Examples
11//!
12//! ```rust,no_run
13//! # async fn example() -> Result<(), zeph_memory::MemoryError> {
14//! use zeph_memory::store::DbStore;
15//!
16//! let store = DbStore::new(":memory:").await?;
17//! store.upsert_channel_preference("cli", "", "provider", "fast").await?;
18//! let value = store.load_channel_preference("cli", "", "provider").await?;
19//! assert_eq!(value.as_deref(), Some("fast"));
20//! # Ok(())
21//! # }
22//! ```
23
24use zeph_db::sql;
25
26use super::SqliteStore;
27use crate::error::MemoryError;
28
29impl SqliteStore {
30    /// Persist or update a single preference value for a `(channel_type, channel_id)` pair.
31    ///
32    /// Uses an upsert (INSERT OR REPLACE) so repeated calls with the same key overwrite
33    /// the previous value and refresh `updated_at`.
34    ///
35    /// # Arguments
36    ///
37    /// - `channel_type` — channel kind: `"cli"`, `"tui"`, `"telegram"`, `"discord"`.
38    /// - `channel_id` — user/chat scope within the channel type. Use `""` for CLI/TUI.
39    /// - `key` — preference key (e.g. `"provider"`).
40    /// - `value` — preference value to store.
41    ///
42    /// # Errors
43    ///
44    /// Returns [`MemoryError`] if the database query fails.
45    pub async fn upsert_channel_preference(
46        &self,
47        channel_type: &str,
48        channel_id: &str,
49        key: &str,
50        value: &str,
51    ) -> Result<(), MemoryError> {
52        // Saturate at i64::MAX (~292 million years from epoch) to avoid clippy::cast_possible_truncation.
53        #[allow(clippy::cast_possible_truncation)]
54        let now_ms = i64::try_from(
55            std::time::SystemTime::now()
56                .duration_since(std::time::UNIX_EPOCH)
57                .unwrap_or_default()
58                .as_millis()
59                .min(i64::MAX as u128),
60        )
61        .unwrap_or(i64::MAX);
62
63        zeph_db::query(sql!(
64            "INSERT INTO channel_preferences \
65             (channel_type, channel_id, pref_key, pref_value, updated_at) \
66             VALUES (?, ?, ?, ?, ?) \
67             ON CONFLICT(channel_type, channel_id, pref_key) DO UPDATE SET \
68               pref_value = excluded.pref_value, \
69               updated_at = excluded.updated_at"
70        ))
71        .bind(channel_type)
72        .bind(channel_id)
73        .bind(key)
74        .bind(value)
75        .bind(now_ms)
76        .execute(&self.pool)
77        .await?;
78
79        Ok(())
80    }
81
82    /// Load a single preference value for a `(channel_type, channel_id)` pair.
83    ///
84    /// Returns `None` when no value has been stored for the given key.
85    ///
86    /// # Arguments
87    ///
88    /// - `channel_type` — channel kind: `"cli"`, `"tui"`, `"telegram"`, `"discord"`.
89    /// - `channel_id` — user/chat scope. Use `""` for CLI/TUI.
90    /// - `key` — preference key to look up (e.g. `"provider"`).
91    ///
92    /// # Errors
93    ///
94    /// Returns [`MemoryError`] if the database query fails.
95    pub async fn load_channel_preference(
96        &self,
97        channel_type: &str,
98        channel_id: &str,
99        key: &str,
100    ) -> Result<Option<String>, MemoryError> {
101        let row: Option<(String,)> = zeph_db::query_as(sql!(
102            "SELECT pref_value FROM channel_preferences \
103             WHERE channel_type = ? AND channel_id = ? AND pref_key = ?"
104        ))
105        .bind(channel_type)
106        .bind(channel_id)
107        .bind(key)
108        .fetch_optional(&self.pool)
109        .await?;
110
111        Ok(row.map(|(v,)| v))
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    async fn store() -> SqliteStore {
120        SqliteStore::new(":memory:").await.unwrap()
121    }
122
123    #[tokio::test]
124    async fn upsert_and_load_roundtrip() {
125        let s = store().await;
126        s.upsert_channel_preference("cli", "", "provider", "fast")
127            .await
128            .unwrap();
129
130        let val = s
131            .load_channel_preference("cli", "", "provider")
132            .await
133            .unwrap();
134        assert_eq!(val.as_deref(), Some("fast"));
135    }
136
137    #[tokio::test]
138    async fn upsert_overwrites_existing_value() {
139        let s = store().await;
140        s.upsert_channel_preference("cli", "", "provider", "fast")
141            .await
142            .unwrap();
143        s.upsert_channel_preference("cli", "", "provider", "quality")
144            .await
145            .unwrap();
146
147        let val = s
148            .load_channel_preference("cli", "", "provider")
149            .await
150            .unwrap();
151        assert_eq!(val.as_deref(), Some("quality"));
152    }
153
154    #[tokio::test]
155    async fn load_returns_none_when_missing() {
156        let s = store().await;
157        let val = s
158            .load_channel_preference("cli", "", "provider")
159            .await
160            .unwrap();
161        assert!(val.is_none());
162    }
163
164    #[tokio::test]
165    async fn composite_key_is_unique_per_channel_type_and_id() {
166        let s = store().await;
167        // Same key, different channel_type → independent values.
168        s.upsert_channel_preference("cli", "", "provider", "cli-provider")
169            .await
170            .unwrap();
171        s.upsert_channel_preference("tui", "", "provider", "tui-provider")
172            .await
173            .unwrap();
174
175        let cli = s
176            .load_channel_preference("cli", "", "provider")
177            .await
178            .unwrap();
179        let tui = s
180            .load_channel_preference("tui", "", "provider")
181            .await
182            .unwrap();
183        assert_eq!(cli.as_deref(), Some("cli-provider"));
184        assert_eq!(tui.as_deref(), Some("tui-provider"));
185    }
186
187    #[tokio::test]
188    async fn composite_key_is_unique_per_channel_id() {
189        let s = store().await;
190        // Same channel_type, different channel_id (e.g. Telegram chat IDs).
191        s.upsert_channel_preference("telegram", "123", "provider", "fast")
192            .await
193            .unwrap();
194        s.upsert_channel_preference("telegram", "456", "provider", "quality")
195            .await
196            .unwrap();
197
198        let chat123 = s
199            .load_channel_preference("telegram", "123", "provider")
200            .await
201            .unwrap();
202        let chat456 = s
203            .load_channel_preference("telegram", "456", "provider")
204            .await
205            .unwrap();
206        assert_eq!(chat123.as_deref(), Some("fast"));
207        assert_eq!(chat456.as_deref(), Some("quality"));
208    }
209
210    #[tokio::test]
211    async fn multiple_keys_per_channel() {
212        let s = store().await;
213        s.upsert_channel_preference("cli", "", "provider", "fast")
214            .await
215            .unwrap();
216        s.upsert_channel_preference("cli", "", "theme", "dark")
217            .await
218            .unwrap();
219
220        let provider = s
221            .load_channel_preference("cli", "", "provider")
222            .await
223            .unwrap();
224        let theme = s.load_channel_preference("cli", "", "theme").await.unwrap();
225        assert_eq!(provider.as_deref(), Some("fast"));
226        assert_eq!(theme.as_deref(), Some("dark"));
227    }
228}