Skip to main content

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