intent_engine/
report.rs

1use crate::db::models::{DateRange, Event, Report, ReportSummary, StatusBreakdown, Task};
2use crate::error::Result;
3use chrono::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| crate::time_utils::parse_duration(&s).ok());
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, active_form");
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, active_form");
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(crate::sql_constants::SELECT_EVENT_BASE);
84            let mut event_conditions = Vec::new();
85
86            if let Some(ref dt) = since_datetime {
87                event_query.push_str(" AND timestamp >= ?");
88                event_conditions.push(dt.to_rfc3339());
89            }
90
91            event_query.push_str(" ORDER BY timestamp DESC");
92
93            let mut q = sqlx::query_as::<_, Event>(&event_query);
94            for cond in &event_conditions {
95                q = q.bind(cond);
96            }
97
98            Some(q.fetch_all(self.pool).await?)
99        } else {
100            None
101        };
102
103        let total_events = if let Some(ref evts) = events {
104            evts.len() as i64
105        } else {
106            sqlx::query_scalar(crate::sql_constants::COUNT_EVENTS_TOTAL)
107                .fetch_one(self.pool)
108                .await?
109        };
110
111        let date_range = since_datetime.map(|from| DateRange {
112            from,
113            to: Utc::now(),
114        });
115
116        Ok(Report {
117            summary: ReportSummary {
118                total_tasks,
119                tasks_by_status: StatusBreakdown {
120                    todo: todo_count,
121                    doing: doing_count,
122                    done: done_count,
123                },
124                total_events,
125                date_range,
126            },
127            tasks: if summary_only { None } else { Some(tasks) },
128            events,
129        })
130    }
131
132    /// Filter tasks using FTS5
133    async fn filter_tasks_by_fts(
134        &self,
135        filter_name: &Option<String>,
136        filter_spec: &Option<String>,
137    ) -> Result<Vec<i64>> {
138        let mut query = String::from("SELECT rowid FROM tasks_fts WHERE ");
139        let mut conditions = Vec::new();
140
141        if let Some(name_filter) = filter_name {
142            conditions.push(format!(
143                "name MATCH '{}'",
144                crate::search::escape_fts5(name_filter)
145            ));
146        }
147
148        if let Some(spec_filter) = filter_spec {
149            conditions.push(format!(
150                "spec MATCH '{}'",
151                crate::search::escape_fts5(spec_filter)
152            ));
153        }
154
155        if conditions.is_empty() {
156            return Ok(Vec::new());
157        }
158
159        query.push_str(&conditions.join(" AND "));
160
161        let ids: Vec<i64> = sqlx::query_scalar(&query).fetch_all(self.pool).await?;
162
163        Ok(ids)
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::events::EventManager;
171    use crate::tasks::TaskManager;
172    use crate::test_utils::test_helpers::TestContext;
173
174    #[tokio::test]
175    async fn test_generate_report_summary_only() {
176        let ctx = TestContext::new().await;
177        let task_mgr = TaskManager::new(ctx.pool());
178        let report_mgr = ReportManager::new(ctx.pool());
179
180        // Create tasks with different statuses
181        task_mgr.add_task("Todo task", None, None).await.unwrap();
182        let doing = task_mgr.add_task("Doing task", None, None).await.unwrap();
183        task_mgr.start_task(doing.id, false).await.unwrap();
184        let done = task_mgr.add_task("Done task", None, None).await.unwrap();
185        task_mgr.start_task(done.id, false).await.unwrap();
186        task_mgr.done_task().await.unwrap();
187
188        let report = report_mgr
189            .generate_report(None, None, None, None, true)
190            .await
191            .unwrap();
192
193        assert_eq!(report.summary.total_tasks, 3);
194        assert_eq!(report.summary.tasks_by_status.todo, 1);
195        assert_eq!(report.summary.tasks_by_status.doing, 1);
196        assert_eq!(report.summary.tasks_by_status.done, 1);
197        assert!(report.tasks.is_none());
198        assert!(report.events.is_none());
199    }
200
201    #[tokio::test]
202    async fn test_generate_report_full() {
203        let ctx = TestContext::new().await;
204        let task_mgr = TaskManager::new(ctx.pool());
205        let report_mgr = ReportManager::new(ctx.pool());
206
207        task_mgr.add_task("Task 1", None, None).await.unwrap();
208        task_mgr.add_task("Task 2", None, None).await.unwrap();
209
210        let report = report_mgr
211            .generate_report(None, None, None, None, false)
212            .await
213            .unwrap();
214
215        assert!(report.tasks.is_some());
216        assert_eq!(report.tasks.unwrap().len(), 2);
217    }
218
219    #[tokio::test]
220    async fn test_generate_report_filter_by_status() {
221        let ctx = TestContext::new().await;
222        let task_mgr = TaskManager::new(ctx.pool());
223        let report_mgr = ReportManager::new(ctx.pool());
224
225        task_mgr.add_task("Todo task", None, None).await.unwrap();
226        let doing = task_mgr.add_task("Doing task", None, None).await.unwrap();
227        task_mgr.start_task(doing.id, false).await.unwrap();
228
229        let report = report_mgr
230            .generate_report(None, Some("doing".to_string()), None, None, false)
231            .await
232            .unwrap();
233
234        let tasks = report.tasks.unwrap();
235        assert_eq!(tasks.len(), 1);
236        assert_eq!(tasks[0].status, "doing");
237    }
238
239    #[tokio::test]
240    async fn test_generate_report_with_events() {
241        let ctx = TestContext::new().await;
242        let task_mgr = TaskManager::new(ctx.pool());
243        let event_mgr = EventManager::new(ctx.pool());
244        let report_mgr = ReportManager::new(ctx.pool());
245
246        let task = task_mgr.add_task("Task 1", None, None).await.unwrap();
247        event_mgr
248            .add_event(task.id, "decision", "Test event")
249            .await
250            .unwrap();
251
252        let report = report_mgr
253            .generate_report(None, None, None, None, false)
254            .await
255            .unwrap();
256
257        assert!(report.events.is_some());
258        assert_eq!(report.summary.total_events, 1);
259    }
260
261    #[tokio::test]
262    async fn test_parse_duration_days() {
263        let result = crate::time_utils::parse_duration("7d").ok();
264        assert!(result.is_some());
265    }
266
267    #[tokio::test]
268    async fn test_parse_duration_hours() {
269        let result = crate::time_utils::parse_duration("24h").ok();
270        assert!(result.is_some());
271    }
272
273    #[tokio::test]
274    async fn test_parse_duration_invalid() {
275        let result = crate::time_utils::parse_duration("invalid").ok();
276        assert!(result.is_none());
277    }
278
279    #[tokio::test]
280    async fn test_filter_tasks_by_fts_name() {
281        let ctx = TestContext::new().await;
282        let task_mgr = TaskManager::new(ctx.pool());
283        let report_mgr = ReportManager::new(ctx.pool());
284
285        task_mgr
286            .add_task("Authentication feature", None, None)
287            .await
288            .unwrap();
289        task_mgr
290            .add_task("Database migration", None, None)
291            .await
292            .unwrap();
293
294        let report = report_mgr
295            .generate_report(None, None, Some("Authentication".to_string()), None, false)
296            .await
297            .unwrap();
298
299        let tasks = report.tasks.unwrap();
300        assert_eq!(tasks.len(), 1);
301        assert!(tasks[0].name.contains("Authentication"));
302    }
303
304    #[tokio::test]
305    async fn test_empty_report() {
306        let ctx = TestContext::new().await;
307        let report_mgr = ReportManager::new(ctx.pool());
308
309        let report = report_mgr
310            .generate_report(None, None, None, None, true)
311            .await
312            .unwrap();
313
314        assert_eq!(report.summary.total_tasks, 0);
315        assert_eq!(report.summary.total_events, 0);
316    }
317
318    #[tokio::test]
319    async fn test_report_filter_consistency() {
320        let ctx = TestContext::new().await;
321        let task_mgr = TaskManager::new(ctx.pool());
322        let report_mgr = ReportManager::new(ctx.pool());
323
324        // Create tasks with different statuses
325        task_mgr.add_task("Task A", None, None).await.unwrap();
326        task_mgr.add_task("Task B", None, None).await.unwrap();
327        let doing = task_mgr.add_task("Task C", None, None).await.unwrap();
328        task_mgr.start_task(doing.id, false).await.unwrap();
329
330        // Filter with non-existent spec should return consistent summary
331        let report = report_mgr
332            .generate_report(None, None, None, Some("JWT".to_string()), true)
333            .await
334            .unwrap();
335
336        // All counts should be 0 since no tasks match the filter
337        assert_eq!(report.summary.total_tasks, 0);
338        assert_eq!(report.summary.tasks_by_status.todo, 0);
339        assert_eq!(report.summary.tasks_by_status.doing, 0);
340        assert_eq!(report.summary.tasks_by_status.done, 0);
341    }
342
343    #[tokio::test]
344    async fn test_generate_report_with_since() {
345        let ctx = TestContext::new().await;
346        let task_mgr = TaskManager::new(ctx.pool());
347        let report_mgr = ReportManager::new(ctx.pool());
348
349        // Create some tasks
350        task_mgr.add_task("Old task", None, None).await.unwrap();
351        task_mgr.add_task("Recent task", None, None).await.unwrap();
352
353        // Query with since parameter (should include all tasks created just now)
354        let report = report_mgr
355            .generate_report(Some("1h".to_string()), None, None, None, true)
356            .await
357            .unwrap();
358
359        // Should include recent tasks
360        assert!(report.summary.total_tasks >= 2);
361        assert!(report.summary.date_range.is_some());
362    }
363
364    #[tokio::test]
365    async fn test_generate_report_filter_by_spec() {
366        let ctx = TestContext::new().await;
367        let task_mgr = TaskManager::new(ctx.pool());
368        let report_mgr = ReportManager::new(ctx.pool());
369
370        task_mgr
371            .add_task("Task 1", Some("Implement authentication using JWT"), None)
372            .await
373            .unwrap();
374        task_mgr
375            .add_task("Task 2", Some("Setup database migrations"), None)
376            .await
377            .unwrap();
378
379        let report = report_mgr
380            .generate_report(None, None, None, Some("authentication".to_string()), false)
381            .await
382            .unwrap();
383
384        let tasks = report.tasks.unwrap();
385        assert_eq!(tasks.len(), 1);
386        assert_eq!(tasks[0].name, "Task 1");
387    }
388
389    #[tokio::test]
390    async fn test_generate_report_combined_status_and_since() {
391        let ctx = TestContext::new().await;
392        let task_mgr = TaskManager::new(ctx.pool());
393        let report_mgr = ReportManager::new(ctx.pool());
394
395        task_mgr.add_task("Todo task", None, None).await.unwrap();
396        let doing = task_mgr.add_task("Doing task", None, None).await.unwrap();
397        task_mgr.start_task(doing.id, false).await.unwrap();
398
399        // Filter by status + since
400        let report = report_mgr
401            .generate_report(
402                Some("1d".to_string()),
403                Some("doing".to_string()),
404                None,
405                None,
406                false,
407            )
408            .await
409            .unwrap();
410
411        let tasks = report.tasks.unwrap();
412        assert_eq!(tasks.len(), 1);
413        assert_eq!(tasks[0].status, "doing");
414    }
415
416    #[tokio::test]
417    async fn test_filter_tasks_by_fts_spec() {
418        let ctx = TestContext::new().await;
419        let task_mgr = TaskManager::new(ctx.pool());
420        let report_mgr = ReportManager::new(ctx.pool());
421
422        task_mgr
423            .add_task("Feature A", Some("Implement JWT authentication"), None)
424            .await
425            .unwrap();
426        task_mgr
427            .add_task("Feature B", Some("Setup OAuth2 integration"), None)
428            .await
429            .unwrap();
430
431        let ids = report_mgr
432            .filter_tasks_by_fts(&None, &Some("JWT".to_string()))
433            .await
434            .unwrap();
435
436        assert_eq!(ids.len(), 1);
437    }
438
439    #[tokio::test]
440    async fn test_filter_tasks_by_fts_both_name_and_spec() {
441        let ctx = TestContext::new().await;
442        let task_mgr = TaskManager::new(ctx.pool());
443        let report_mgr = ReportManager::new(ctx.pool());
444
445        task_mgr
446            .add_task("Auth feature", Some("Implement authentication"), None)
447            .await
448            .unwrap();
449        task_mgr
450            .add_task(
451                "Database setup",
452                Some("Configure authentication database"),
453                None,
454            )
455            .await
456            .unwrap();
457
458        // Both name and spec contain "auth"
459        let ids = report_mgr
460            .filter_tasks_by_fts(
461                &Some("Auth".to_string()),
462                &Some("authentication".to_string()),
463            )
464            .await
465            .unwrap();
466
467        assert_eq!(ids.len(), 1);
468    }
469
470    #[tokio::test]
471    async fn test_filter_tasks_by_fts_empty() {
472        let ctx = TestContext::new().await;
473        let report_mgr = ReportManager::new(ctx.pool());
474
475        // Empty filters should return empty vec
476        let ids = report_mgr.filter_tasks_by_fts(&None, &None).await.unwrap();
477
478        assert_eq!(ids.len(), 0);
479    }
480
481    #[tokio::test]
482    async fn test_report_date_range_present() {
483        let ctx = TestContext::new().await;
484        let task_mgr = TaskManager::new(ctx.pool());
485        let report_mgr = ReportManager::new(ctx.pool());
486
487        task_mgr.add_task("Task", None, None).await.unwrap();
488
489        let report = report_mgr
490            .generate_report(Some("7d".to_string()), None, None, None, true)
491            .await
492            .unwrap();
493
494        // date_range should be present when since is specified
495        assert!(report.summary.date_range.is_some());
496        let date_range = report.summary.date_range.unwrap();
497        assert!(date_range.to > date_range.from);
498    }
499
500    #[tokio::test]
501    async fn test_report_date_range_absent() {
502        let ctx = TestContext::new().await;
503        let task_mgr = TaskManager::new(ctx.pool());
504        let report_mgr = ReportManager::new(ctx.pool());
505
506        task_mgr.add_task("Task", None, None).await.unwrap();
507
508        let report = report_mgr
509            .generate_report(None, None, None, None, true)
510            .await
511            .unwrap();
512
513        // date_range should be None when since is not specified
514        assert!(report.summary.date_range.is_none());
515    }
516
517    #[tokio::test]
518    async fn test_report_events_count_consistency() {
519        let ctx = TestContext::new().await;
520        let task_mgr = TaskManager::new(ctx.pool());
521        let event_mgr = EventManager::new(ctx.pool());
522        let report_mgr = ReportManager::new(ctx.pool());
523
524        let task = task_mgr.add_task("Task", None, None).await.unwrap();
525        event_mgr
526            .add_event(task.id, "decision", "Event 1")
527            .await
528            .unwrap();
529        event_mgr
530            .add_event(task.id, "note", "Event 2")
531            .await
532            .unwrap();
533
534        // summary_only should still count events
535        let summary_report = report_mgr
536            .generate_report(None, None, None, None, true)
537            .await
538            .unwrap();
539        assert_eq!(summary_report.summary.total_events, 2);
540        assert!(summary_report.events.is_none());
541
542        // Full report should include events
543        let full_report = report_mgr
544            .generate_report(None, None, None, None, false)
545            .await
546            .unwrap();
547        assert_eq!(full_report.summary.total_events, 2);
548        assert_eq!(full_report.events.unwrap().len(), 2);
549    }
550
551    #[tokio::test]
552    async fn test_generate_report_all_filters_combined() {
553        let ctx = TestContext::new().await;
554        let task_mgr = TaskManager::new(ctx.pool());
555        let report_mgr = ReportManager::new(ctx.pool());
556
557        task_mgr
558            .add_task("Auth feature", Some("JWT implementation"), None)
559            .await
560            .unwrap();
561        let doing = task_mgr
562            .add_task("Auth testing", Some("Write JWT tests"), None)
563            .await
564            .unwrap();
565        task_mgr.start_task(doing.id, false).await.unwrap();
566
567        // Combine all filters: since + status + name + spec
568        let report = report_mgr
569            .generate_report(
570                Some("1h".to_string()),
571                Some("doing".to_string()),
572                Some("Auth".to_string()),
573                Some("JWT".to_string()),
574                false,
575            )
576            .await
577            .unwrap();
578
579        let tasks = report.tasks.unwrap();
580        assert_eq!(tasks.len(), 1);
581        assert_eq!(tasks[0].status, "doing");
582        assert!(tasks[0].name.contains("Auth"));
583    }
584}