Skip to main content

punch_memory/
channels.rs

1//! Channel persistence -- stores and loads channel configuration records.
2//!
3//! Channel records are stored in the `channels` table, keyed by name
4//! so they survive restarts and can be managed via the CLI or API.
5
6use serde::{Deserialize, Serialize};
7use tracing::debug;
8
9use punch_types::{PunchError, PunchResult};
10
11use crate::MemorySubstrate;
12
13/// A persisted channel configuration record.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ChannelRecord {
16    /// Unique identifier for this channel.
17    pub id: String,
18    /// Human-readable name for this channel (unique).
19    pub name: String,
20    /// Platform identifier (e.g., "telegram", "slack").
21    pub platform: String,
22    /// JSON-encoded credentials.
23    pub credentials: String,
24    /// JSON-encoded settings.
25    pub settings: String,
26    /// Connection status (e.g., "connected", "disconnected", "error").
27    pub status: String,
28    /// When credentials were last validated.
29    pub validated_at: Option<String>,
30    /// When the record was created.
31    pub created_at: String,
32    /// When the record was last updated.
33    pub updated_at: String,
34}
35
36impl MemorySubstrate {
37    /// Save or update a channel record. Uses name as the natural key.
38    pub async fn save_channel(&self, record: &ChannelRecord) -> PunchResult<()> {
39        let conn = self.conn.lock().await;
40        conn.execute(
41            "INSERT INTO channels (id, name, platform, credentials, settings, status, validated_at, created_at, updated_at)
42             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
43             ON CONFLICT(name) DO UPDATE SET
44                platform = excluded.platform,
45                credentials = excluded.credentials,
46                settings = excluded.settings,
47                status = excluded.status,
48                validated_at = excluded.validated_at,
49                updated_at = excluded.updated_at",
50            rusqlite::params![
51                record.id,
52                record.name,
53                record.platform,
54                record.credentials,
55                record.settings,
56                record.status,
57                record.validated_at,
58                record.created_at,
59                record.updated_at,
60            ],
61        )
62        .map_err(|e| PunchError::Memory(format!("failed to save channel: {e}")))?;
63
64        debug!(name = %record.name, platform = %record.platform, "channel saved");
65        Ok(())
66    }
67
68    /// Load a channel by name. Returns None if no channel exists.
69    pub async fn load_channel(&self, name: &str) -> PunchResult<Option<ChannelRecord>> {
70        let conn = self.conn.lock().await;
71        let mut stmt = conn
72            .prepare(
73                "SELECT id, name, platform, credentials, settings, status, validated_at, created_at, updated_at
74                 FROM channels WHERE name = ?1",
75            )
76            .map_err(|e| PunchError::Memory(format!("failed to prepare channel query: {e}")))?;
77
78        let result = stmt
79            .query_row([name], |row| {
80                Ok(ChannelRecord {
81                    id: row.get(0)?,
82                    name: row.get(1)?,
83                    platform: row.get(2)?,
84                    credentials: row.get(3)?,
85                    settings: row.get(4)?,
86                    status: row.get(5)?,
87                    validated_at: row.get(6)?,
88                    created_at: row.get(7)?,
89                    updated_at: row.get(8)?,
90                })
91            })
92            .ok();
93
94        Ok(result)
95    }
96
97    /// List all channel records.
98    pub async fn list_channels(&self) -> PunchResult<Vec<ChannelRecord>> {
99        let conn = self.conn.lock().await;
100        let mut stmt = conn
101            .prepare(
102                "SELECT id, name, platform, credentials, settings, status, validated_at, created_at, updated_at
103                 FROM channels ORDER BY created_at DESC",
104            )
105            .map_err(|e| PunchError::Memory(format!("failed to list channels: {e}")))?;
106
107        let rows = stmt
108            .query_map([], |row| {
109                Ok(ChannelRecord {
110                    id: row.get(0)?,
111                    name: row.get(1)?,
112                    platform: row.get(2)?,
113                    credentials: row.get(3)?,
114                    settings: row.get(4)?,
115                    status: row.get(5)?,
116                    validated_at: row.get(6)?,
117                    created_at: row.get(7)?,
118                    updated_at: row.get(8)?,
119                })
120            })
121            .map_err(|e| PunchError::Memory(format!("failed to read channel rows: {e}")))?;
122
123        let mut channels = Vec::new();
124        for row in rows {
125            let record =
126                row.map_err(|e| PunchError::Memory(format!("failed to read channel: {e}")))?;
127            channels.push(record);
128        }
129        Ok(channels)
130    }
131
132    /// Delete a channel by name.
133    pub async fn delete_channel(&self, name: &str) -> PunchResult<()> {
134        let conn = self.conn.lock().await;
135        conn.execute("DELETE FROM channels WHERE name = ?1", [name])
136            .map_err(|e| PunchError::Memory(format!("failed to delete channel: {e}")))?;
137        debug!(name = name, "channel deleted");
138        Ok(())
139    }
140
141    /// Update the status of a channel by name.
142    pub async fn update_channel_status(&self, name: &str, status: &str) -> PunchResult<()> {
143        let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
144        let conn = self.conn.lock().await;
145        conn.execute(
146            "UPDATE channels SET status = ?1, updated_at = ?2 WHERE name = ?3",
147            rusqlite::params![status, now, name],
148        )
149        .map_err(|e| PunchError::Memory(format!("failed to update channel status: {e}")))?;
150        debug!(name = name, status = status, "channel status updated");
151        Ok(())
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::MemorySubstrate;
159
160    fn make_record(name: &str, platform: &str) -> ChannelRecord {
161        let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
162        ChannelRecord {
163            id: uuid::Uuid::new_v4().to_string(),
164            name: name.to_string(),
165            platform: platform.to_string(),
166            credentials: "{}".to_string(),
167            settings: "{}".to_string(),
168            status: "disconnected".to_string(),
169            validated_at: None,
170            created_at: now.clone(),
171            updated_at: now,
172        }
173    }
174
175    #[tokio::test]
176    async fn test_save_and_load_channel() {
177        let substrate = MemorySubstrate::in_memory().unwrap();
178        let record = make_record("my-telegram", "telegram");
179
180        substrate.save_channel(&record).await.unwrap();
181        let loaded = substrate.load_channel("my-telegram").await.unwrap();
182        assert!(loaded.is_some());
183        let loaded = loaded.unwrap();
184        assert_eq!(loaded.name, "my-telegram");
185        assert_eq!(loaded.platform, "telegram");
186        assert_eq!(loaded.status, "disconnected");
187    }
188
189    #[tokio::test]
190    async fn test_save_channel_upsert() {
191        let substrate = MemorySubstrate::in_memory().unwrap();
192        let mut record = make_record("my-slack", "slack");
193        substrate.save_channel(&record).await.unwrap();
194
195        record.status = "connected".to_string();
196        substrate.save_channel(&record).await.unwrap();
197
198        let loaded = substrate.load_channel("my-slack").await.unwrap().unwrap();
199        assert_eq!(loaded.status, "connected");
200    }
201
202    #[tokio::test]
203    async fn test_list_channels() {
204        let substrate = MemorySubstrate::in_memory().unwrap();
205        substrate
206            .save_channel(&make_record("ch-1", "telegram"))
207            .await
208            .unwrap();
209        substrate
210            .save_channel(&make_record("ch-2", "slack"))
211            .await
212            .unwrap();
213        substrate
214            .save_channel(&make_record("ch-3", "discord"))
215            .await
216            .unwrap();
217
218        let channels = substrate.list_channels().await.unwrap();
219        assert_eq!(channels.len(), 3);
220    }
221
222    #[tokio::test]
223    async fn test_delete_channel() {
224        let substrate = MemorySubstrate::in_memory().unwrap();
225        substrate
226            .save_channel(&make_record("to-delete", "telegram"))
227            .await
228            .unwrap();
229
230        assert!(substrate.load_channel("to-delete").await.unwrap().is_some());
231
232        substrate.delete_channel("to-delete").await.unwrap();
233        assert!(substrate.load_channel("to-delete").await.unwrap().is_none());
234    }
235
236    #[tokio::test]
237    async fn test_update_channel_status() {
238        let substrate = MemorySubstrate::in_memory().unwrap();
239        substrate
240            .save_channel(&make_record("status-test", "discord"))
241            .await
242            .unwrap();
243
244        substrate
245            .update_channel_status("status-test", "connected")
246            .await
247            .unwrap();
248
249        let loaded = substrate
250            .load_channel("status-test")
251            .await
252            .unwrap()
253            .unwrap();
254        assert_eq!(loaded.status, "connected");
255    }
256
257    #[tokio::test]
258    async fn test_load_nonexistent_channel() {
259        let substrate = MemorySubstrate::in_memory().unwrap();
260        let result = substrate.load_channel("nonexistent").await.unwrap();
261        assert!(result.is_none());
262    }
263}