intent_engine/
report.rs

1use crate::db::models::{DateRange, Event, Report, ReportSummary, StatusBreakdown, Task};
2use crate::error::Result;
3use chrono::{Duration, Utc};
4use sqlx::SqlitePool;
5
6pub struct ReportManager<'a> {
7    pool: &'a SqlitePool,
8}
9
10impl<'a> ReportManager<'a> {
11    pub fn new(pool: &'a SqlitePool) -> Self {
12        Self { pool }
13    }
14
15    /// Generate a report with optional filters
16    pub async fn generate_report(
17        &self,
18        since: Option<String>,
19        status: Option<String>,
20        filter_name: Option<String>,
21        filter_spec: Option<String>,
22        summary_only: bool,
23    ) -> Result<Report> {
24        // Parse duration if provided
25        let since_datetime = since.and_then(|s| self.parse_duration(&s));
26
27        // Build task query
28        let mut task_query = String::from("SELECT id FROM tasks WHERE 1=1");
29        let mut task_conditions = Vec::new();
30
31        if let Some(ref status) = status {
32            task_query.push_str(" AND status = ?");
33            task_conditions.push(status.clone());
34        }
35
36        if let Some(ref dt) = since_datetime {
37            task_query.push_str(" AND first_todo_at >= ?");
38            task_conditions.push(dt.to_rfc3339());
39        }
40
41        // Add FTS5 filters
42        let task_ids = if filter_name.is_some() || filter_spec.is_some() {
43            self.filter_tasks_by_fts(&filter_name, &filter_spec).await?
44        } else {
45            Vec::new()
46        };
47
48        // If FTS filters were applied, intersect with other filters
49        let tasks = if !task_ids.is_empty() {
50            task_query.push_str(&format!(
51                " AND id IN ({})",
52                task_ids.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
53            ));
54            let full_query = task_query.replace("SELECT id", "SELECT id, parent_id, name, NULL as spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at");
55            let mut q = sqlx::query_as::<_, Task>(&full_query);
56            for cond in &task_conditions {
57                q = q.bind(cond);
58            }
59            for id in &task_ids {
60                q = q.bind(id);
61            }
62            q.fetch_all(self.pool).await?
63        } else if filter_name.is_none() && filter_spec.is_none() {
64            let full_query = task_query.replace("SELECT id", "SELECT id, parent_id, name, NULL as spec, status, complexity, priority, first_todo_at, first_doing_at, first_done_at");
65            let mut q = sqlx::query_as::<_, Task>(&full_query);
66            for cond in &task_conditions {
67                q = q.bind(cond);
68            }
69            q.fetch_all(self.pool).await?
70        } else {
71            Vec::new()
72        };
73
74        // Count tasks by status from filtered results
75        let todo_count = tasks.iter().filter(|t| t.status == "todo").count() as i64;
76        let doing_count = tasks.iter().filter(|t| t.status == "doing").count() as i64;
77        let done_count = tasks.iter().filter(|t| t.status == "done").count() as i64;
78
79        let total_tasks = tasks.len() as i64;
80
81        // Get events
82        let events = if !summary_only {
83            let mut event_query = String::from(
84                "SELECT id, task_id, timestamp, log_type, discussion_data FROM events WHERE 1=1",
85            );
86            let mut event_conditions = Vec::new();
87
88            if let Some(ref dt) = since_datetime {
89                event_query.push_str(" AND timestamp >= ?");
90                event_conditions.push(dt.to_rfc3339());
91            }
92
93            event_query.push_str(" ORDER BY timestamp DESC");
94
95            let mut q = sqlx::query_as::<_, Event>(&event_query);
96            for cond in &event_conditions {
97                q = q.bind(cond);
98            }
99
100            Some(q.fetch_all(self.pool).await?)
101        } else {
102            None
103        };
104
105        let total_events = if let Some(ref evts) = events {
106            evts.len() as i64
107        } else {
108            sqlx::query_scalar("SELECT COUNT(*) FROM events")
109                .fetch_one(self.pool)
110                .await?
111        };
112
113        let date_range = since_datetime.map(|from| DateRange {
114            from,
115            to: Utc::now(),
116        });
117
118        Ok(Report {
119            summary: ReportSummary {
120                total_tasks,
121                tasks_by_status: StatusBreakdown {
122                    todo: todo_count,
123                    doing: doing_count,
124                    done: done_count,
125                },
126                total_events,
127                date_range,
128            },
129            tasks: if summary_only { None } else { Some(tasks) },
130            events,
131        })
132    }
133
134    /// Filter tasks using FTS5
135    async fn filter_tasks_by_fts(
136        &self,
137        filter_name: &Option<String>,
138        filter_spec: &Option<String>,
139    ) -> Result<Vec<i64>> {
140        let mut query = String::from("SELECT rowid FROM tasks_fts WHERE ");
141        let mut conditions = Vec::new();
142
143        if let Some(name_filter) = filter_name {
144            conditions.push(format!("name MATCH '{}'", self.escape_fts(name_filter)));
145        }
146
147        if let Some(spec_filter) = filter_spec {
148            conditions.push(format!("spec MATCH '{}'", self.escape_fts(spec_filter)));
149        }
150
151        if conditions.is_empty() {
152            return Ok(Vec::new());
153        }
154
155        query.push_str(&conditions.join(" AND "));
156
157        let ids: Vec<i64> = sqlx::query_scalar(&query).fetch_all(self.pool).await?;
158
159        Ok(ids)
160    }
161
162    /// Escape FTS5 special characters
163    fn escape_fts(&self, input: &str) -> String {
164        input.replace('"', "\"\"")
165    }
166
167    /// Parse duration string (e.g., "7d", "2h", "30m")
168    fn parse_duration(&self, duration_str: &str) -> Option<chrono::DateTime<Utc>> {
169        let duration_str = duration_str.trim();
170
171        if duration_str.is_empty() {
172            return None;
173        }
174
175        let len = duration_str.len();
176        let (num_part, unit) = duration_str.split_at(len - 1);
177
178        let num: i64 = num_part.parse().ok()?;
179
180        let duration = match unit {
181            "d" => Duration::days(num),
182            "h" => Duration::hours(num),
183            "m" => Duration::minutes(num),
184            "w" => Duration::weeks(num),
185            _ => return None,
186        };
187
188        Some(Utc::now() - duration)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::events::EventManager;
196    use crate::tasks::TaskManager;
197    use crate::test_utils::test_helpers::TestContext;
198
199    #[tokio::test]
200    async fn test_generate_report_summary_only() {
201        let ctx = TestContext::new().await;
202        let task_mgr = TaskManager::new(ctx.pool());
203        let report_mgr = ReportManager::new(ctx.pool());
204
205        // Create tasks with different statuses
206        task_mgr.add_task("Todo task", None, None).await.unwrap();
207        let doing = task_mgr.add_task("Doing task", None, None).await.unwrap();
208        task_mgr.start_task(doing.id, false).await.unwrap();
209        let done = task_mgr.add_task("Done task", None, None).await.unwrap();
210        task_mgr.start_task(done.id, false).await.unwrap();
211        task_mgr.done_task().await.unwrap();
212
213        let report = report_mgr
214            .generate_report(None, None, None, None, true)
215            .await
216            .unwrap();
217
218        assert_eq!(report.summary.total_tasks, 3);
219        assert_eq!(report.summary.tasks_by_status.todo, 1);
220        assert_eq!(report.summary.tasks_by_status.doing, 1);
221        assert_eq!(report.summary.tasks_by_status.done, 1);
222        assert!(report.tasks.is_none());
223        assert!(report.events.is_none());
224    }
225
226    #[tokio::test]
227    async fn test_generate_report_full() {
228        let ctx = TestContext::new().await;
229        let task_mgr = TaskManager::new(ctx.pool());
230        let report_mgr = ReportManager::new(ctx.pool());
231
232        task_mgr.add_task("Task 1", None, None).await.unwrap();
233        task_mgr.add_task("Task 2", None, None).await.unwrap();
234
235        let report = report_mgr
236            .generate_report(None, None, None, None, false)
237            .await
238            .unwrap();
239
240        assert!(report.tasks.is_some());
241        assert_eq!(report.tasks.unwrap().len(), 2);
242    }
243
244    #[tokio::test]
245    async fn test_generate_report_filter_by_status() {
246        let ctx = TestContext::new().await;
247        let task_mgr = TaskManager::new(ctx.pool());
248        let report_mgr = ReportManager::new(ctx.pool());
249
250        task_mgr.add_task("Todo task", None, None).await.unwrap();
251        let doing = task_mgr.add_task("Doing task", None, None).await.unwrap();
252        task_mgr.start_task(doing.id, false).await.unwrap();
253
254        let report = report_mgr
255            .generate_report(None, Some("doing".to_string()), None, None, false)
256            .await
257            .unwrap();
258
259        let tasks = report.tasks.unwrap();
260        assert_eq!(tasks.len(), 1);
261        assert_eq!(tasks[0].status, "doing");
262    }
263
264    #[tokio::test]
265    async fn test_generate_report_with_events() {
266        let ctx = TestContext::new().await;
267        let task_mgr = TaskManager::new(ctx.pool());
268        let event_mgr = EventManager::new(ctx.pool());
269        let report_mgr = ReportManager::new(ctx.pool());
270
271        let task = task_mgr.add_task("Task 1", None, None).await.unwrap();
272        event_mgr
273            .add_event(task.id, "decision", "Test event")
274            .await
275            .unwrap();
276
277        let report = report_mgr
278            .generate_report(None, None, None, None, false)
279            .await
280            .unwrap();
281
282        assert!(report.events.is_some());
283        assert_eq!(report.summary.total_events, 1);
284    }
285
286    #[tokio::test]
287    async fn test_parse_duration_days() {
288        let ctx = TestContext::new().await;
289        let report_mgr = ReportManager::new(ctx.pool());
290
291        let result = report_mgr.parse_duration("7d");
292        assert!(result.is_some());
293    }
294
295    #[tokio::test]
296    async fn test_parse_duration_hours() {
297        let ctx = TestContext::new().await;
298        let report_mgr = ReportManager::new(ctx.pool());
299
300        let result = report_mgr.parse_duration("24h");
301        assert!(result.is_some());
302    }
303
304    #[tokio::test]
305    async fn test_parse_duration_invalid() {
306        let ctx = TestContext::new().await;
307        let report_mgr = ReportManager::new(ctx.pool());
308
309        let result = report_mgr.parse_duration("invalid");
310        assert!(result.is_none());
311    }
312
313    #[tokio::test]
314    async fn test_filter_tasks_by_fts_name() {
315        let ctx = TestContext::new().await;
316        let task_mgr = TaskManager::new(ctx.pool());
317        let report_mgr = ReportManager::new(ctx.pool());
318
319        task_mgr
320            .add_task("Authentication feature", None, None)
321            .await
322            .unwrap();
323        task_mgr
324            .add_task("Database migration", None, None)
325            .await
326            .unwrap();
327
328        let report = report_mgr
329            .generate_report(None, None, Some("Authentication".to_string()), None, false)
330            .await
331            .unwrap();
332
333        let tasks = report.tasks.unwrap();
334        assert_eq!(tasks.len(), 1);
335        assert!(tasks[0].name.contains("Authentication"));
336    }
337
338    #[tokio::test]
339    async fn test_empty_report() {
340        let ctx = TestContext::new().await;
341        let report_mgr = ReportManager::new(ctx.pool());
342
343        let report = report_mgr
344            .generate_report(None, None, None, None, true)
345            .await
346            .unwrap();
347
348        assert_eq!(report.summary.total_tasks, 0);
349        assert_eq!(report.summary.total_events, 0);
350    }
351
352    #[tokio::test]
353    async fn test_report_filter_consistency() {
354        let ctx = TestContext::new().await;
355        let task_mgr = TaskManager::new(ctx.pool());
356        let report_mgr = ReportManager::new(ctx.pool());
357
358        // Create tasks with different statuses
359        task_mgr.add_task("Task A", None, None).await.unwrap();
360        task_mgr.add_task("Task B", None, None).await.unwrap();
361        let doing = task_mgr.add_task("Task C", None, None).await.unwrap();
362        task_mgr.start_task(doing.id, false).await.unwrap();
363
364        // Filter with non-existent spec should return consistent summary
365        let report = report_mgr
366            .generate_report(None, None, None, Some("JWT".to_string()), true)
367            .await
368            .unwrap();
369
370        // All counts should be 0 since no tasks match the filter
371        assert_eq!(report.summary.total_tasks, 0);
372        assert_eq!(report.summary.tasks_by_status.todo, 0);
373        assert_eq!(report.summary.tasks_by_status.doing, 0);
374        assert_eq!(report.summary.tasks_by_status.done, 0);
375    }
376}