intent_engine/
events.rs

1use crate::db::models::Event;
2use crate::error::{IntentError, Result};
3use chrono::Utc;
4use sqlx::{Row, SqlitePool};
5
6pub struct EventManager<'a> {
7    pool: &'a SqlitePool,
8}
9
10impl<'a> EventManager<'a> {
11    pub fn new(pool: &'a SqlitePool) -> Self {
12        Self { pool }
13    }
14
15    /// Add a new event
16    pub async fn add_event(
17        &self,
18        task_id: i64,
19        log_type: &str,
20        discussion_data: &str,
21    ) -> Result<Event> {
22        // Check if task exists
23        let task_exists: bool =
24            sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tasks WHERE id = ?)")
25                .bind(task_id)
26                .fetch_one(self.pool)
27                .await?;
28
29        if !task_exists {
30            return Err(IntentError::TaskNotFound(task_id));
31        }
32
33        let now = Utc::now();
34
35        let result = sqlx::query(
36            r#"
37            INSERT INTO events (task_id, log_type, discussion_data, timestamp)
38            VALUES (?, ?, ?, ?)
39            "#,
40        )
41        .bind(task_id)
42        .bind(log_type)
43        .bind(discussion_data)
44        .bind(now)
45        .execute(self.pool)
46        .await?;
47
48        let id = result.last_insert_rowid();
49
50        Ok(Event {
51            id,
52            task_id,
53            timestamp: now,
54            log_type: log_type.to_string(),
55            discussion_data: discussion_data.to_string(),
56        })
57    }
58
59    /// List events for a task (or globally if task_id is None)
60    pub async fn list_events(
61        &self,
62        task_id: Option<i64>,
63        limit: Option<i64>,
64        log_type: Option<String>,
65        since: Option<String>,
66    ) -> Result<Vec<Event>> {
67        // Check if task exists (only if task_id provided)
68        if let Some(tid) = task_id {
69            let task_exists: bool =
70                sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tasks WHERE id = ?)")
71                    .bind(tid)
72                    .fetch_one(self.pool)
73                    .await?;
74
75            if !task_exists {
76                return Err(IntentError::TaskNotFound(tid));
77            }
78        }
79
80        let limit = limit.unwrap_or(50);
81
82        // Parse since duration if provided
83        let since_timestamp = if let Some(duration_str) = since {
84            Some(Self::parse_duration(&duration_str)?)
85        } else {
86            None
87        };
88
89        // Build dynamic query based on filters
90        let mut query = String::from(
91            "SELECT id, task_id, timestamp, log_type, discussion_data FROM events WHERE 1=1",
92        );
93        let mut conditions = Vec::new();
94
95        if task_id.is_some() {
96            conditions.push("task_id = ?");
97        }
98
99        if log_type.is_some() {
100            conditions.push("log_type = ?");
101        }
102
103        if since_timestamp.is_some() {
104            conditions.push("timestamp >= ?");
105        }
106
107        if !conditions.is_empty() {
108            query.push_str(" AND ");
109            query.push_str(&conditions.join(" AND "));
110        }
111
112        query.push_str(" ORDER BY timestamp DESC LIMIT ?");
113
114        // Build and execute query
115        let mut sql_query = sqlx::query_as::<_, Event>(&query);
116
117        if let Some(tid) = task_id {
118            sql_query = sql_query.bind(tid);
119        }
120
121        if let Some(ref typ) = log_type {
122            sql_query = sql_query.bind(typ);
123        }
124
125        if let Some(ts) = since_timestamp {
126            sql_query = sql_query.bind(ts);
127        }
128
129        sql_query = sql_query.bind(limit);
130
131        let events = sql_query.fetch_all(self.pool).await?;
132
133        Ok(events)
134    }
135
136    /// Parse duration string (e.g., "7d", "24h", "30m") into a DateTime
137    fn parse_duration(duration: &str) -> Result<chrono::DateTime<Utc>> {
138        let len = duration.len();
139        if len < 2 {
140            return Err(IntentError::InvalidInput(
141                "Duration must be in format like '7d', '24h', or '30m'".to_string(),
142            ));
143        }
144
145        let (num_str, unit) = duration.split_at(len - 1);
146        let num: i64 = num_str.parse().map_err(|_| {
147            IntentError::InvalidInput(format!("Invalid number in duration: {}", num_str))
148        })?;
149
150        let now = Utc::now();
151        let result = match unit {
152            "d" => now - chrono::Duration::days(num),
153            "h" => now - chrono::Duration::hours(num),
154            "m" => now - chrono::Duration::minutes(num),
155            "s" => now - chrono::Duration::seconds(num),
156            _ => {
157                return Err(IntentError::InvalidInput(format!(
158                    "Invalid duration unit '{}'. Use 'd' (days), 'h' (hours), 'm' (minutes), or 's' (seconds)",
159                    unit
160                )))
161            }
162        };
163
164        Ok(result)
165    }
166
167    /// Search events using FTS5
168    pub async fn search_events_fts5(
169        &self,
170        query: &str,
171        limit: Option<i64>,
172    ) -> Result<Vec<EventSearchResult>> {
173        let limit = limit.unwrap_or(20);
174
175        // Use FTS5 to search events and get snippets
176        let results = sqlx::query(
177            r#"
178            SELECT
179                e.id,
180                e.task_id,
181                e.timestamp,
182                e.log_type,
183                e.discussion_data,
184                snippet(events_fts, 0, '**', '**', '...', 15) as match_snippet
185            FROM events_fts
186            INNER JOIN events e ON events_fts.rowid = e.id
187            WHERE events_fts MATCH ?
188            ORDER BY rank
189            LIMIT ?
190            "#,
191        )
192        .bind(query)
193        .bind(limit)
194        .fetch_all(self.pool)
195        .await?;
196
197        let mut search_results = Vec::new();
198        for row in results {
199            let event = Event {
200                id: row.get("id"),
201                task_id: row.get("task_id"),
202                timestamp: row.get("timestamp"),
203                log_type: row.get("log_type"),
204                discussion_data: row.get("discussion_data"),
205            };
206            let match_snippet: String = row.get("match_snippet");
207
208            search_results.push(EventSearchResult {
209                event,
210                match_snippet,
211            });
212        }
213
214        Ok(search_results)
215    }
216}
217
218/// Event search result with match snippet
219#[derive(Debug)]
220pub struct EventSearchResult {
221    pub event: Event,
222    pub match_snippet: String,
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::tasks::TaskManager;
229    use crate::test_utils::test_helpers::TestContext;
230
231    #[tokio::test]
232    async fn test_add_event() {
233        let ctx = TestContext::new().await;
234        let task_mgr = TaskManager::new(ctx.pool());
235        let event_mgr = EventManager::new(ctx.pool());
236
237        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
238        let event = event_mgr
239            .add_event(task.id, "decision", "Test decision")
240            .await
241            .unwrap();
242
243        assert_eq!(event.task_id, task.id);
244        assert_eq!(event.log_type, "decision");
245        assert_eq!(event.discussion_data, "Test decision");
246    }
247
248    #[tokio::test]
249    async fn test_add_event_nonexistent_task() {
250        let ctx = TestContext::new().await;
251        let event_mgr = EventManager::new(ctx.pool());
252
253        let result = event_mgr.add_event(999, "decision", "Test").await;
254        assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
255    }
256
257    #[tokio::test]
258    async fn test_list_events() {
259        let ctx = TestContext::new().await;
260        let task_mgr = TaskManager::new(ctx.pool());
261        let event_mgr = EventManager::new(ctx.pool());
262
263        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
264
265        // Add multiple events
266        event_mgr
267            .add_event(task.id, "decision", "Decision 1")
268            .await
269            .unwrap();
270        event_mgr
271            .add_event(task.id, "blocker", "Blocker 1")
272            .await
273            .unwrap();
274        event_mgr
275            .add_event(task.id, "milestone", "Milestone 1")
276            .await
277            .unwrap();
278
279        let events = event_mgr
280            .list_events(Some(task.id), None, None, None)
281            .await
282            .unwrap();
283        assert_eq!(events.len(), 3);
284
285        // Events should be in reverse chronological order
286        assert_eq!(events[0].log_type, "milestone");
287        assert_eq!(events[1].log_type, "blocker");
288        assert_eq!(events[2].log_type, "decision");
289    }
290
291    #[tokio::test]
292    async fn test_list_events_with_limit() {
293        let ctx = TestContext::new().await;
294        let task_mgr = TaskManager::new(ctx.pool());
295        let event_mgr = EventManager::new(ctx.pool());
296
297        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
298
299        // Add 5 events
300        for i in 0..5 {
301            event_mgr
302                .add_event(task.id, "test", &format!("Event {}", i))
303                .await
304                .unwrap();
305        }
306
307        let events = event_mgr
308            .list_events(Some(task.id), Some(3), None, None)
309            .await
310            .unwrap();
311        assert_eq!(events.len(), 3);
312    }
313
314    #[tokio::test]
315    async fn test_list_events_nonexistent_task() {
316        let ctx = TestContext::new().await;
317        let event_mgr = EventManager::new(ctx.pool());
318
319        let result = event_mgr.list_events(Some(999), None, None, None).await;
320        assert!(matches!(result, Err(IntentError::TaskNotFound(999))));
321    }
322
323    #[tokio::test]
324    async fn test_list_events_empty() {
325        let ctx = TestContext::new().await;
326        let task_mgr = TaskManager::new(ctx.pool());
327        let event_mgr = EventManager::new(ctx.pool());
328
329        let task = task_mgr.add_task("Test task", None, None).await.unwrap();
330
331        let events = event_mgr
332            .list_events(Some(task.id), None, None, None)
333            .await
334            .unwrap();
335        assert_eq!(events.len(), 0);
336    }
337}