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, owner");
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, owner");
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::<_, i64>(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
182            .add_task("Todo task", None, None, None)
183            .await
184            .unwrap();
185        let doing = task_mgr
186            .add_task("Doing task", None, None, None)
187            .await
188            .unwrap();
189        task_mgr.start_task(doing.id, false).await.unwrap();
190        let done = task_mgr
191            .add_task("Done task", None, None, None)
192            .await
193            .unwrap();
194        task_mgr.start_task(done.id, false).await.unwrap();
195        task_mgr.done_task(false).await.unwrap();
196
197        let report = report_mgr
198            .generate_report(None, None, None, None, true)
199            .await
200            .unwrap();
201
202        assert_eq!(report.summary.total_tasks, 3);
203        assert_eq!(report.summary.tasks_by_status.todo, 1);
204        assert_eq!(report.summary.tasks_by_status.doing, 1);
205        assert_eq!(report.summary.tasks_by_status.done, 1);
206        assert!(report.tasks.is_none());
207        assert!(report.events.is_none());
208    }
209
210    #[tokio::test]
211    async fn test_generate_report_full() {
212        let ctx = TestContext::new().await;
213        let task_mgr = TaskManager::new(ctx.pool());
214        let report_mgr = ReportManager::new(ctx.pool());
215
216        task_mgr.add_task("Task 1", None, None, None).await.unwrap();
217        task_mgr.add_task("Task 2", None, None, None).await.unwrap();
218
219        let report = report_mgr
220            .generate_report(None, None, None, None, false)
221            .await
222            .unwrap();
223
224        assert!(report.tasks.is_some());
225        assert_eq!(report.tasks.unwrap().len(), 2);
226    }
227
228    #[tokio::test]
229    async fn test_generate_report_filter_by_status() {
230        let ctx = TestContext::new().await;
231        let task_mgr = TaskManager::new(ctx.pool());
232        let report_mgr = ReportManager::new(ctx.pool());
233
234        task_mgr
235            .add_task("Todo task", None, None, None)
236            .await
237            .unwrap();
238        let doing = task_mgr
239            .add_task("Doing task", None, None, None)
240            .await
241            .unwrap();
242        task_mgr.start_task(doing.id, false).await.unwrap();
243
244        let report = report_mgr
245            .generate_report(None, Some("doing".to_string()), None, None, false)
246            .await
247            .unwrap();
248
249        let tasks = report.tasks.unwrap();
250        assert_eq!(tasks.len(), 1);
251        assert_eq!(tasks[0].status, "doing");
252    }
253
254    #[tokio::test]
255    async fn test_generate_report_with_events() {
256        let ctx = TestContext::new().await;
257        let task_mgr = TaskManager::new(ctx.pool());
258        let event_mgr = EventManager::new(ctx.pool());
259        let report_mgr = ReportManager::new(ctx.pool());
260
261        let task = task_mgr.add_task("Task 1", None, None, None).await.unwrap();
262        event_mgr
263            .add_event(task.id, "decision", "Test event")
264            .await
265            .unwrap();
266
267        let report = report_mgr
268            .generate_report(None, None, None, None, false)
269            .await
270            .unwrap();
271
272        assert!(report.events.is_some());
273        assert_eq!(report.summary.total_events, 1);
274    }
275
276    #[tokio::test]
277    async fn test_parse_duration_days() {
278        let result = crate::time_utils::parse_duration("7d").ok();
279        assert!(result.is_some());
280    }
281
282    #[tokio::test]
283    async fn test_parse_duration_hours() {
284        let result = crate::time_utils::parse_duration("24h").ok();
285        assert!(result.is_some());
286    }
287
288    #[tokio::test]
289    async fn test_parse_duration_invalid() {
290        let result = crate::time_utils::parse_duration("invalid").ok();
291        assert!(result.is_none());
292    }
293
294    #[tokio::test]
295    async fn test_filter_tasks_by_fts_name() {
296        let ctx = TestContext::new().await;
297        let task_mgr = TaskManager::new(ctx.pool());
298        let report_mgr = ReportManager::new(ctx.pool());
299
300        task_mgr
301            .add_task("Authentication feature", None, None, None)
302            .await
303            .unwrap();
304        task_mgr
305            .add_task("Database migration", None, None, None)
306            .await
307            .unwrap();
308
309        let report = report_mgr
310            .generate_report(None, None, Some("Authentication".to_string()), None, false)
311            .await
312            .unwrap();
313
314        let tasks = report.tasks.unwrap();
315        assert_eq!(tasks.len(), 1);
316        assert!(tasks[0].name.contains("Authentication"));
317    }
318
319    #[tokio::test]
320    async fn test_empty_report() {
321        let ctx = TestContext::new().await;
322        let report_mgr = ReportManager::new(ctx.pool());
323
324        let report = report_mgr
325            .generate_report(None, None, None, None, true)
326            .await
327            .unwrap();
328
329        assert_eq!(report.summary.total_tasks, 0);
330        assert_eq!(report.summary.total_events, 0);
331    }
332
333    #[tokio::test]
334    async fn test_report_filter_consistency() {
335        let ctx = TestContext::new().await;
336        let task_mgr = TaskManager::new(ctx.pool());
337        let report_mgr = ReportManager::new(ctx.pool());
338
339        // Create tasks with different statuses
340        task_mgr.add_task("Task A", None, None, None).await.unwrap();
341        task_mgr.add_task("Task B", None, None, None).await.unwrap();
342        let doing = task_mgr.add_task("Task C", None, None, None).await.unwrap();
343        task_mgr.start_task(doing.id, false).await.unwrap();
344
345        // Filter with non-existent spec should return consistent summary
346        let report = report_mgr
347            .generate_report(None, None, None, Some("JWT".to_string()), true)
348            .await
349            .unwrap();
350
351        // All counts should be 0 since no tasks match the filter
352        assert_eq!(report.summary.total_tasks, 0);
353        assert_eq!(report.summary.tasks_by_status.todo, 0);
354        assert_eq!(report.summary.tasks_by_status.doing, 0);
355        assert_eq!(report.summary.tasks_by_status.done, 0);
356    }
357
358    #[tokio::test]
359    async fn test_generate_report_with_since() {
360        let ctx = TestContext::new().await;
361        let task_mgr = TaskManager::new(ctx.pool());
362        let report_mgr = ReportManager::new(ctx.pool());
363
364        // Create some tasks
365        task_mgr
366            .add_task("Old task", None, None, None)
367            .await
368            .unwrap();
369        task_mgr
370            .add_task("Recent task", None, None, None)
371            .await
372            .unwrap();
373
374        // Query with since parameter (should include all tasks created just now)
375        let report = report_mgr
376            .generate_report(Some("1h".to_string()), None, None, None, true)
377            .await
378            .unwrap();
379
380        // Should include recent tasks
381        assert!(report.summary.total_tasks >= 2);
382        assert!(report.summary.date_range.is_some());
383    }
384
385    #[tokio::test]
386    async fn test_generate_report_filter_by_spec() {
387        let ctx = TestContext::new().await;
388        let task_mgr = TaskManager::new(ctx.pool());
389        let report_mgr = ReportManager::new(ctx.pool());
390
391        task_mgr
392            .add_task(
393                "Task 1",
394                Some("Implement authentication using JWT"),
395                None,
396                None,
397            )
398            .await
399            .unwrap();
400        task_mgr
401            .add_task("Task 2", Some("Setup database migrations"), None, None)
402            .await
403            .unwrap();
404
405        let report = report_mgr
406            .generate_report(None, None, None, Some("authentication".to_string()), false)
407            .await
408            .unwrap();
409
410        let tasks = report.tasks.unwrap();
411        assert_eq!(tasks.len(), 1);
412        assert_eq!(tasks[0].name, "Task 1");
413    }
414
415    #[tokio::test]
416    async fn test_generate_report_combined_status_and_since() {
417        let ctx = TestContext::new().await;
418        let task_mgr = TaskManager::new(ctx.pool());
419        let report_mgr = ReportManager::new(ctx.pool());
420
421        task_mgr
422            .add_task("Todo task", None, None, None)
423            .await
424            .unwrap();
425        let doing = task_mgr
426            .add_task("Doing task", None, None, None)
427            .await
428            .unwrap();
429        task_mgr.start_task(doing.id, false).await.unwrap();
430
431        // Filter by status + since
432        let report = report_mgr
433            .generate_report(
434                Some("1d".to_string()),
435                Some("doing".to_string()),
436                None,
437                None,
438                false,
439            )
440            .await
441            .unwrap();
442
443        let tasks = report.tasks.unwrap();
444        assert_eq!(tasks.len(), 1);
445        assert_eq!(tasks[0].status, "doing");
446    }
447
448    #[tokio::test]
449    async fn test_filter_tasks_by_fts_spec() {
450        let ctx = TestContext::new().await;
451        let task_mgr = TaskManager::new(ctx.pool());
452        let report_mgr = ReportManager::new(ctx.pool());
453
454        task_mgr
455            .add_task(
456                "Feature A",
457                Some("Implement JWT authentication"),
458                None,
459                None,
460            )
461            .await
462            .unwrap();
463        task_mgr
464            .add_task("Feature B", Some("Setup OAuth2 integration"), None, None)
465            .await
466            .unwrap();
467
468        let ids = report_mgr
469            .filter_tasks_by_fts(&None, &Some("JWT".to_string()))
470            .await
471            .unwrap();
472
473        assert_eq!(ids.len(), 1);
474    }
475
476    #[tokio::test]
477    async fn test_filter_tasks_by_fts_both_name_and_spec() {
478        let ctx = TestContext::new().await;
479        let task_mgr = TaskManager::new(ctx.pool());
480        let report_mgr = ReportManager::new(ctx.pool());
481
482        task_mgr
483            .add_task("Auth feature", Some("Implement authentication"), None, None)
484            .await
485            .unwrap();
486        task_mgr
487            .add_task(
488                "Database setup",
489                Some("Configure authentication database"),
490                None,
491                None,
492            )
493            .await
494            .unwrap();
495
496        // Both name and spec contain "auth"
497        let ids = report_mgr
498            .filter_tasks_by_fts(
499                &Some("Auth".to_string()),
500                &Some("authentication".to_string()),
501            )
502            .await
503            .unwrap();
504
505        assert_eq!(ids.len(), 1);
506    }
507
508    #[tokio::test]
509    async fn test_filter_tasks_by_fts_empty() {
510        let ctx = TestContext::new().await;
511        let report_mgr = ReportManager::new(ctx.pool());
512
513        // Empty filters should return empty vec
514        let ids = report_mgr.filter_tasks_by_fts(&None, &None).await.unwrap();
515
516        assert_eq!(ids.len(), 0);
517    }
518
519    #[tokio::test]
520    async fn test_report_date_range_present() {
521        let ctx = TestContext::new().await;
522        let task_mgr = TaskManager::new(ctx.pool());
523        let report_mgr = ReportManager::new(ctx.pool());
524
525        task_mgr.add_task("Task", None, None, None).await.unwrap();
526
527        let report = report_mgr
528            .generate_report(Some("7d".to_string()), None, None, None, true)
529            .await
530            .unwrap();
531
532        // date_range should be present when since is specified
533        assert!(report.summary.date_range.is_some());
534        let date_range = report.summary.date_range.unwrap();
535        assert!(date_range.to > date_range.from);
536    }
537
538    #[tokio::test]
539    async fn test_report_date_range_absent() {
540        let ctx = TestContext::new().await;
541        let task_mgr = TaskManager::new(ctx.pool());
542        let report_mgr = ReportManager::new(ctx.pool());
543
544        task_mgr.add_task("Task", None, None, None).await.unwrap();
545
546        let report = report_mgr
547            .generate_report(None, None, None, None, true)
548            .await
549            .unwrap();
550
551        // date_range should be None when since is not specified
552        assert!(report.summary.date_range.is_none());
553    }
554
555    #[tokio::test]
556    async fn test_report_events_count_consistency() {
557        let ctx = TestContext::new().await;
558        let task_mgr = TaskManager::new(ctx.pool());
559        let event_mgr = EventManager::new(ctx.pool());
560        let report_mgr = ReportManager::new(ctx.pool());
561
562        let task = task_mgr.add_task("Task", None, None, None).await.unwrap();
563        event_mgr
564            .add_event(task.id, "decision", "Event 1")
565            .await
566            .unwrap();
567        event_mgr
568            .add_event(task.id, "note", "Event 2")
569            .await
570            .unwrap();
571
572        // summary_only should still count events
573        let summary_report = report_mgr
574            .generate_report(None, None, None, None, true)
575            .await
576            .unwrap();
577        assert_eq!(summary_report.summary.total_events, 2);
578        assert!(summary_report.events.is_none());
579
580        // Full report should include events
581        let full_report = report_mgr
582            .generate_report(None, None, None, None, false)
583            .await
584            .unwrap();
585        assert_eq!(full_report.summary.total_events, 2);
586        assert_eq!(full_report.events.unwrap().len(), 2);
587    }
588
589    #[tokio::test]
590    async fn test_generate_report_all_filters_combined() {
591        let ctx = TestContext::new().await;
592        let task_mgr = TaskManager::new(ctx.pool());
593        let report_mgr = ReportManager::new(ctx.pool());
594
595        task_mgr
596            .add_task("Auth feature", Some("JWT implementation"), None, None)
597            .await
598            .unwrap();
599        let doing = task_mgr
600            .add_task("Auth testing", Some("Write JWT tests"), None, None)
601            .await
602            .unwrap();
603        task_mgr.start_task(doing.id, false).await.unwrap();
604
605        // Combine all filters: since + status + name + spec
606        let report = report_mgr
607            .generate_report(
608                Some("1h".to_string()),
609                Some("doing".to_string()),
610                Some("Auth".to_string()),
611                Some("JWT".to_string()),
612                false,
613            )
614            .await
615            .unwrap();
616
617        let tasks = report.tasks.unwrap();
618        assert_eq!(tasks.len(), 1);
619        assert_eq!(tasks[0].status, "doing");
620        assert!(tasks[0].name.contains("Auth"));
621    }
622}