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
75        let todo_count: i64 =
76            sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE status = 'todo'")
77                .fetch_one(self.pool)
78                .await?;
79
80        let doing_count: i64 =
81            sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE status = 'doing'")
82                .fetch_one(self.pool)
83                .await?;
84
85        let done_count: i64 =
86            sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE status = 'done'")
87                .fetch_one(self.pool)
88                .await?;
89
90        let total_tasks = tasks.len() as i64;
91
92        // Get events
93        let events = if !summary_only {
94            let mut event_query = String::from(
95                "SELECT id, task_id, timestamp, log_type, discussion_data FROM events WHERE 1=1",
96            );
97            let mut event_conditions = Vec::new();
98
99            if let Some(ref dt) = since_datetime {
100                event_query.push_str(" AND timestamp >= ?");
101                event_conditions.push(dt.to_rfc3339());
102            }
103
104            event_query.push_str(" ORDER BY timestamp DESC");
105
106            let mut q = sqlx::query_as::<_, Event>(&event_query);
107            for cond in &event_conditions {
108                q = q.bind(cond);
109            }
110
111            Some(q.fetch_all(self.pool).await?)
112        } else {
113            None
114        };
115
116        let total_events = if let Some(ref evts) = events {
117            evts.len() as i64
118        } else {
119            sqlx::query_scalar("SELECT COUNT(*) FROM events")
120                .fetch_one(self.pool)
121                .await?
122        };
123
124        let date_range = since_datetime.map(|from| DateRange {
125            from,
126            to: Utc::now(),
127        });
128
129        Ok(Report {
130            summary: ReportSummary {
131                total_tasks,
132                tasks_by_status: StatusBreakdown {
133                    todo: todo_count,
134                    doing: doing_count,
135                    done: done_count,
136                },
137                total_events,
138                date_range,
139            },
140            tasks: if summary_only { None } else { Some(tasks) },
141            events,
142        })
143    }
144
145    /// Filter tasks using FTS5
146    async fn filter_tasks_by_fts(
147        &self,
148        filter_name: &Option<String>,
149        filter_spec: &Option<String>,
150    ) -> Result<Vec<i64>> {
151        let mut query = String::from("SELECT rowid FROM tasks_fts WHERE ");
152        let mut conditions = Vec::new();
153
154        if let Some(name_filter) = filter_name {
155            conditions.push(format!("name MATCH '{}'", self.escape_fts(name_filter)));
156        }
157
158        if let Some(spec_filter) = filter_spec {
159            conditions.push(format!("spec MATCH '{}'", self.escape_fts(spec_filter)));
160        }
161
162        if conditions.is_empty() {
163            return Ok(Vec::new());
164        }
165
166        query.push_str(&conditions.join(" AND "));
167
168        let ids: Vec<i64> = sqlx::query_scalar(&query).fetch_all(self.pool).await?;
169
170        Ok(ids)
171    }
172
173    /// Escape FTS5 special characters
174    fn escape_fts(&self, input: &str) -> String {
175        input.replace('"', "\"\"")
176    }
177
178    /// Parse duration string (e.g., "7d", "2h", "30m")
179    fn parse_duration(&self, duration_str: &str) -> Option<chrono::DateTime<Utc>> {
180        let duration_str = duration_str.trim();
181
182        if duration_str.is_empty() {
183            return None;
184        }
185
186        let len = duration_str.len();
187        let (num_part, unit) = duration_str.split_at(len - 1);
188
189        let num: i64 = num_part.parse().ok()?;
190
191        let duration = match unit {
192            "d" => Duration::days(num),
193            "h" => Duration::hours(num),
194            "m" => Duration::minutes(num),
195            "w" => Duration::weeks(num),
196            _ => return None,
197        };
198
199        Some(Utc::now() - duration)
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::events::EventManager;
207    use crate::tasks::TaskManager;
208    use crate::test_utils::test_helpers::TestContext;
209
210    #[tokio::test]
211    async fn test_generate_report_summary_only() {
212        let ctx = TestContext::new().await;
213        let task_mgr = TaskManager::new(ctx.pool());
214        let report_mgr = ReportManager::new(ctx.pool());
215
216        // Create tasks with different statuses
217        task_mgr.add_task("Todo task", None, None).await.unwrap();
218        let doing = task_mgr.add_task("Doing task", None, None).await.unwrap();
219        task_mgr.start_task(doing.id, false).await.unwrap();
220        let done = task_mgr.add_task("Done task", None, None).await.unwrap();
221        task_mgr.done_task(done.id).await.unwrap();
222
223        let report = report_mgr
224            .generate_report(None, None, None, None, true)
225            .await
226            .unwrap();
227
228        assert_eq!(report.summary.total_tasks, 3);
229        assert_eq!(report.summary.tasks_by_status.todo, 1);
230        assert_eq!(report.summary.tasks_by_status.doing, 1);
231        assert_eq!(report.summary.tasks_by_status.done, 1);
232        assert!(report.tasks.is_none());
233        assert!(report.events.is_none());
234    }
235
236    #[tokio::test]
237    async fn test_generate_report_full() {
238        let ctx = TestContext::new().await;
239        let task_mgr = TaskManager::new(ctx.pool());
240        let report_mgr = ReportManager::new(ctx.pool());
241
242        task_mgr.add_task("Task 1", None, None).await.unwrap();
243        task_mgr.add_task("Task 2", None, None).await.unwrap();
244
245        let report = report_mgr
246            .generate_report(None, None, None, None, false)
247            .await
248            .unwrap();
249
250        assert!(report.tasks.is_some());
251        assert_eq!(report.tasks.unwrap().len(), 2);
252    }
253
254    #[tokio::test]
255    async fn test_generate_report_filter_by_status() {
256        let ctx = TestContext::new().await;
257        let task_mgr = TaskManager::new(ctx.pool());
258        let report_mgr = ReportManager::new(ctx.pool());
259
260        task_mgr.add_task("Todo task", None, None).await.unwrap();
261        let doing = task_mgr.add_task("Doing task", None, None).await.unwrap();
262        task_mgr.start_task(doing.id, false).await.unwrap();
263
264        let report = report_mgr
265            .generate_report(None, Some("doing".to_string()), None, None, false)
266            .await
267            .unwrap();
268
269        let tasks = report.tasks.unwrap();
270        assert_eq!(tasks.len(), 1);
271        assert_eq!(tasks[0].status, "doing");
272    }
273
274    #[tokio::test]
275    async fn test_generate_report_with_events() {
276        let ctx = TestContext::new().await;
277        let task_mgr = TaskManager::new(ctx.pool());
278        let event_mgr = EventManager::new(ctx.pool());
279        let report_mgr = ReportManager::new(ctx.pool());
280
281        let task = task_mgr.add_task("Task 1", None, None).await.unwrap();
282        event_mgr
283            .add_event(task.id, "decision", "Test event")
284            .await
285            .unwrap();
286
287        let report = report_mgr
288            .generate_report(None, None, None, None, false)
289            .await
290            .unwrap();
291
292        assert!(report.events.is_some());
293        assert_eq!(report.summary.total_events, 1);
294    }
295
296    #[tokio::test]
297    async fn test_parse_duration_days() {
298        let ctx = TestContext::new().await;
299        let report_mgr = ReportManager::new(ctx.pool());
300
301        let result = report_mgr.parse_duration("7d");
302        assert!(result.is_some());
303    }
304
305    #[tokio::test]
306    async fn test_parse_duration_hours() {
307        let ctx = TestContext::new().await;
308        let report_mgr = ReportManager::new(ctx.pool());
309
310        let result = report_mgr.parse_duration("24h");
311        assert!(result.is_some());
312    }
313
314    #[tokio::test]
315    async fn test_parse_duration_invalid() {
316        let ctx = TestContext::new().await;
317        let report_mgr = ReportManager::new(ctx.pool());
318
319        let result = report_mgr.parse_duration("invalid");
320        assert!(result.is_none());
321    }
322
323    #[tokio::test]
324    async fn test_filter_tasks_by_fts_name() {
325        let ctx = TestContext::new().await;
326        let task_mgr = TaskManager::new(ctx.pool());
327        let report_mgr = ReportManager::new(ctx.pool());
328
329        task_mgr
330            .add_task("Authentication feature", None, None)
331            .await
332            .unwrap();
333        task_mgr
334            .add_task("Database migration", None, None)
335            .await
336            .unwrap();
337
338        let report = report_mgr
339            .generate_report(None, None, Some("Authentication".to_string()), None, false)
340            .await
341            .unwrap();
342
343        let tasks = report.tasks.unwrap();
344        assert_eq!(tasks.len(), 1);
345        assert!(tasks[0].name.contains("Authentication"));
346    }
347
348    #[tokio::test]
349    async fn test_empty_report() {
350        let ctx = TestContext::new().await;
351        let report_mgr = ReportManager::new(ctx.pool());
352
353        let report = report_mgr
354            .generate_report(None, None, None, None, true)
355            .await
356            .unwrap();
357
358        assert_eq!(report.summary.total_tasks, 0);
359        assert_eq!(report.summary.total_events, 0);
360    }
361}