Skip to main content

intent_engine/db/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sqlx::FromRow;
4
5/// Custom date serialization module - formats to seconds precision
6mod datetime_format {
7    use chrono::{DateTime, Utc};
8    use serde::{self, Deserialize, Deserializer, Serializer};
9
10    const FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
11
12    pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
13    where
14        S: Serializer,
15    {
16        let s = date.format(FORMAT).to_string();
17        serializer.serialize_str(&s)
18    }
19
20    pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
21    where
22        D: Deserializer<'de>,
23    {
24        let s = String::deserialize(deserializer)?;
25        // Try parsing with our format first, then fall back to RFC 3339
26        DateTime::parse_from_str(&s, FORMAT)
27            .map(|dt| dt.with_timezone(&Utc))
28            .or_else(|_| DateTime::parse_from_rfc3339(&s).map(|dt| dt.with_timezone(&Utc)))
29            .map_err(serde::de::Error::custom)
30    }
31}
32
33/// Custom date serialization for `Option<DateTime<Utc>>`
34mod option_datetime_format {
35    use chrono::{DateTime, Utc};
36    use serde::{self, Deserialize, Deserializer, Serializer};
37
38    const FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
39
40    pub fn serialize<S>(date: &Option<DateTime<Utc>>, serializer: S) -> Result<S::Ok, S::Error>
41    where
42        S: Serializer,
43    {
44        match date {
45            Some(dt) => {
46                let s = dt.format(FORMAT).to_string();
47                serializer.serialize_some(&s)
48            },
49            None => serializer.serialize_none(),
50        }
51    }
52
53    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
54    where
55        D: Deserializer<'de>,
56    {
57        let opt: Option<String> = Option::deserialize(deserializer)?;
58        match opt {
59            Some(s) => DateTime::parse_from_str(&s, FORMAT)
60                .map(|dt| Some(dt.with_timezone(&Utc)))
61                .or_else(|_| {
62                    DateTime::parse_from_rfc3339(&s).map(|dt| Some(dt.with_timezone(&Utc)))
63                })
64                .map_err(serde::de::Error::custom),
65            None => Ok(None),
66        }
67    }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
71pub struct Dependency {
72    pub id: i64,
73    pub blocking_task_id: i64,
74    pub blocked_task_id: i64,
75    #[serde(with = "datetime_format")]
76    pub created_at: DateTime<Utc>,
77}
78
79/// Task approval record for human task authorization
80/// AI must provide the passphrase to complete a human-owned task
81#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
82pub struct TaskApproval {
83    pub id: i64,
84    pub task_id: i64,
85    pub passphrase: String,
86    #[serde(with = "datetime_format")]
87    pub created_at: DateTime<Utc>,
88    #[serde(with = "option_datetime_format")]
89    pub expires_at: Option<DateTime<Utc>>,
90}
91
92/// Response for creating a task approval
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ApprovalResponse {
95    pub task_id: i64,
96    pub passphrase: String,
97    #[serde(
98        skip_serializing_if = "Option::is_none",
99        with = "option_datetime_format"
100    )]
101    pub expires_at: Option<DateTime<Utc>>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, FromRow, PartialEq)]
105pub struct Task {
106    pub id: i64,
107    pub parent_id: Option<i64>,
108    pub name: String,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub spec: Option<String>,
111    pub status: String,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub complexity: Option<i32>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub priority: Option<i32>,
116    #[serde(with = "option_datetime_format")]
117    pub first_todo_at: Option<DateTime<Utc>>,
118    #[serde(with = "option_datetime_format")]
119    pub first_doing_at: Option<DateTime<Utc>>,
120    #[serde(with = "option_datetime_format")]
121    pub first_done_at: Option<DateTime<Utc>>,
122    /// Present progressive form for UI display when task is in_progress
123    /// Example: "Implementing authentication" vs "Implement authentication"
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub active_form: Option<String>,
126    /// Task owner: identifies who created the task (e.g. 'human', 'ai', or any custom string)
127    #[serde(default = "default_owner")]
128    pub owner: String,
129    /// Free-form metadata JSON string for extensibility
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub metadata: Option<String>,
132}
133
134fn default_owner() -> String {
135    "human".to_string()
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
139pub struct TaskWithEvents {
140    #[serde(flatten)]
141    pub task: Task,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub events_summary: Option<EventsSummary>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct EventsSummary {
148    pub total_count: i64,
149    pub recent_events: Vec<Event>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, FromRow, PartialEq)]
153pub struct Event {
154    pub id: i64,
155    pub task_id: i64,
156    #[serde(with = "datetime_format")]
157    pub timestamp: DateTime<Utc>,
158    pub log_type: String,
159    pub discussion_data: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
163pub struct WorkspaceState {
164    pub key: String,
165    pub value: String,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct Report {
170    pub summary: ReportSummary,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub tasks: Option<Vec<Task>>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub events: Option<Vec<Event>>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ReportSummary {
179    pub total_tasks: i64,
180    pub tasks_by_status: StatusBreakdown,
181    pub total_events: i64,
182    pub date_range: Option<DateRange>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct StatusBreakdown {
187    pub todo: i64,
188    pub doing: i64,
189    pub done: i64,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct DateRange {
194    #[serde(with = "datetime_format")]
195    pub from: DateTime<Utc>,
196    #[serde(with = "datetime_format")]
197    pub to: DateTime<Utc>,
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct DoneTaskResponse {
202    pub completed_task: Task,
203    pub workspace_status: WorkspaceStatus,
204    pub next_step_suggestion: NextStepSuggestion,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct WorkspaceStatus {
209    pub current_task_id: Option<i64>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(tag = "type")]
214pub enum NextStepSuggestion {
215    #[serde(rename = "PARENT_IS_READY")]
216    ParentIsReady {
217        message: String,
218        parent_task_id: i64,
219        parent_task_name: String,
220    },
221    #[serde(rename = "SIBLING_TASKS_REMAIN")]
222    SiblingTasksRemain {
223        message: String,
224        parent_task_id: i64,
225        parent_task_name: String,
226        remaining_siblings_count: i64,
227    },
228    #[serde(rename = "TOP_LEVEL_TASK_COMPLETED")]
229    TopLevelTaskCompleted {
230        message: String,
231        completed_task_id: i64,
232        completed_task_name: String,
233    },
234    #[serde(rename = "NO_PARENT_CONTEXT")]
235    NoParentContext {
236        message: String,
237        completed_task_id: i64,
238        completed_task_name: String,
239    },
240    #[serde(rename = "WORKSPACE_IS_CLEAR")]
241    WorkspaceIsClear {
242        message: String,
243        completed_task_id: i64,
244    },
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct TaskSearchResult {
249    #[serde(flatten)]
250    pub task: Task,
251    pub match_snippet: String,
252}
253
254/// Unified search result that can represent either a task or event match
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(tag = "result_type")]
257pub enum SearchResult {
258    #[serde(rename = "task")]
259    Task {
260        #[serde(flatten)]
261        task: Task,
262        match_snippet: String,
263        match_field: String, // "name" or "spec"
264    },
265    #[serde(rename = "event")]
266    Event {
267        event: Event,
268        task_chain: Vec<Task>, // Ancestry: [immediate task, parent, grandparent, ...]
269        match_snippet: String,
270    },
271}
272
273/// Paginated search results across tasks and events
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct PaginatedSearchResults {
276    pub results: Vec<SearchResult>,
277    pub total_tasks: i64,
278    pub total_events: i64,
279    pub has_more: bool,
280    pub limit: i64,
281    pub offset: i64,
282}
283
284/// Response for spawn-subtask command - includes subtask and parent info
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct SpawnSubtaskResponse {
287    pub subtask: SubtaskInfo,
288    pub parent_task: ParentTaskInfo,
289}
290
291/// Subtask info for spawn response
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct SubtaskInfo {
294    pub id: i64,
295    pub name: String,
296    pub parent_id: i64,
297    pub status: String,
298}
299
300/// Parent task info for spawn response
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct ParentTaskInfo {
303    pub id: i64,
304    pub name: String,
305}
306
307/// Dependency information for a task
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct TaskDependencies {
310    /// Tasks that must be completed before this task can start
311    pub blocking_tasks: Vec<Task>,
312    /// Tasks that are blocked by this task
313    pub blocked_by_tasks: Vec<Task>,
314}
315
316/// Response for task_context - provides the complete family tree of a task
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct TaskContext {
319    pub task: Task,
320    pub ancestors: Vec<Task>,
321    pub siblings: Vec<Task>,
322    pub children: Vec<Task>,
323    pub dependencies: TaskDependencies,
324}
325
326/// Sort order for task queries
327#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
328#[serde(rename_all = "snake_case")]
329pub enum TaskSortBy {
330    /// Legacy: ORDER BY id ASC (backward compatible)
331    Id,
332    /// ORDER BY priority ASC, complexity ASC, id ASC
333    Priority,
334    /// ORDER BY first_doing_at DESC NULLS LAST, first_todo_at DESC NULLS LAST, id ASC
335    Time,
336    /// Focus-aware: current focused task → doing tasks → todo tasks
337    #[default]
338    FocusAware,
339}
340
341/// Paginated task query results
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct PaginatedTasks {
344    pub tasks: Vec<Task>,
345    pub total_count: i64,
346    pub has_more: bool,
347    pub limit: i64,
348    pub offset: i64,
349}
350
351/// Workspace statistics (aggregated counts without loading tasks)
352#[derive(Debug, Clone, Serialize, Deserialize)]
353pub struct WorkspaceStats {
354    pub total_tasks: i64,
355    pub todo: i64,
356    pub doing: i64,
357    pub done: i64,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct PickNextResponse {
362    pub suggestion_type: String,
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub task: Option<Task>,
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub reason_code: Option<String>,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub message: Option<String>,
369}
370
371impl PickNextResponse {
372    /// Create a response for focused subtask suggestion
373    pub fn focused_subtask(task: Task) -> Self {
374        Self {
375            suggestion_type: "FOCUSED_SUB_TASK".to_string(),
376            task: Some(task),
377            reason_code: None,
378            message: None,
379        }
380    }
381
382    /// Create a response for top-level task suggestion
383    pub fn top_level_task(task: Task) -> Self {
384        Self {
385            suggestion_type: "TOP_LEVEL_TASK".to_string(),
386            task: Some(task),
387            reason_code: None,
388            message: None,
389        }
390    }
391
392    /// Create a response for no tasks in project
393    pub fn no_tasks_in_project() -> Self {
394        Self {
395            suggestion_type: "NONE".to_string(),
396            task: None,
397            reason_code: Some("NO_TASKS_IN_PROJECT".to_string()),
398            message: Some(
399                "No tasks found in this project. Your intent backlog is empty.".to_string(),
400            ),
401        }
402    }
403
404    /// Create a response for all tasks completed
405    pub fn all_tasks_completed() -> Self {
406        Self {
407            suggestion_type: "NONE".to_string(),
408            task: None,
409            reason_code: Some("ALL_TASKS_COMPLETED".to_string()),
410            message: Some("Project Complete! All intents have been realized.".to_string()),
411        }
412    }
413
414    /// Create a response for no available todos
415    pub fn no_available_todos() -> Self {
416        Self {
417            suggestion_type: "NONE".to_string(),
418            task: None,
419            reason_code: Some("NO_AVAILABLE_TODOS".to_string()),
420            message: Some("No immediate next task found based on the current context.".to_string()),
421        }
422    }
423
424    /// Format response as human-readable text
425    pub fn format_as_text(&self) -> String {
426        match self.suggestion_type.as_str() {
427            "FOCUSED_SUB_TASK" | "TOP_LEVEL_TASK" => {
428                if let Some(task) = &self.task {
429                    format!(
430                        "Based on your current focus, the recommended next task is:\n\n\
431                        [ID: {}] [Priority: {}] [Status: {}]\n\
432                        Name: {}\n\n\
433                        To start working on it, run:\n  ie task start {}",
434                        task.id,
435                        task.priority.unwrap_or(0),
436                        task.status,
437                        task.name,
438                        task.id
439                    )
440                } else {
441                    "[ERROR] Invalid response: task is missing".to_string()
442                }
443            },
444            "NONE" => {
445                let reason_code = self.reason_code.as_deref().unwrap_or("UNKNOWN");
446                let message = self.message.as_deref().unwrap_or("No tasks found");
447
448                match reason_code {
449                    "NO_TASKS_IN_PROJECT" => {
450                        format!(
451                            "[INFO] {}\n\n\
452                            To get started, capture your first high-level intent:\n  \
453                            ie task add --name \"Setup initial project structure\" --priority 1",
454                            message
455                        )
456                    },
457                    "ALL_TASKS_COMPLETED" => {
458                        format!(
459                            "[SUCCESS] {}\n\n\
460                            You can review the accomplishments of the last 30 days with:\n  \
461                            ie report --since 30d",
462                            message
463                        )
464                    },
465                    "NO_AVAILABLE_TODOS" => {
466                        format!(
467                            "[INFO] {}\n\n\
468                            Possible reasons:\n\
469                            - All available 'todo' tasks are part of larger epics that have not been started yet.\n\
470                            - You are not currently focused on a task that has 'todo' sub-tasks.\n\n\
471                            To see all available top-level tasks you can start, run:\n  \
472                            ie task find --parent NULL --status todo",
473                            message
474                        )
475                    },
476                    _ => format!("[INFO] {}", message),
477                }
478            },
479            _ => "[ERROR] Unknown suggestion type".to_string(),
480        }
481    }
482}
483
484/// Simplified task info for siblings/descendants in status response
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct TaskBrief {
487    pub id: i64,
488    pub name: String,
489    pub status: String,
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub parent_id: Option<i64>,
492    /// Whether the task has a non-empty spec/description
493    #[serde(default)]
494    pub has_spec: bool,
495}
496
497impl From<&Task> for TaskBrief {
498    fn from(task: &Task) -> Self {
499        Self {
500            id: task.id,
501            name: task.name.clone(),
502            status: task.status.clone(),
503            parent_id: task.parent_id,
504            has_spec: task
505                .spec
506                .as_ref()
507                .map(|s| !s.trim().is_empty())
508                .unwrap_or(false),
509        }
510    }
511}
512
513/// Response for ie status command - the "spotlight" view of a task
514#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct StatusResponse {
516    /// The focused task with full details
517    pub focused_task: Task,
518    /// Ancestor chain from immediate parent to root (full details)
519    pub ancestors: Vec<Task>,
520    /// Sibling tasks (id + name + status)
521    pub siblings: Vec<TaskBrief>,
522    /// All descendant tasks recursively (id + name + status + parent_id)
523    pub descendants: Vec<TaskBrief>,
524    /// Optional event history
525    #[serde(skip_serializing_if = "Option::is_none")]
526    pub events: Option<Vec<Event>>,
527}
528
529/// Response when no task is focused
530#[derive(Debug, Clone, Serialize, Deserialize)]
531pub struct NoFocusResponse {
532    pub message: String,
533    /// Root-level tasks (no parent)
534    pub root_tasks: Vec<TaskBrief>,
535}
536
537/// LLM-generated suggestion for background analysis
538#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
539pub struct Suggestion {
540    pub id: i64,
541    #[serde(rename = "type")]
542    #[sqlx(rename = "type")]
543    pub suggestion_type: String,
544    pub content: String,
545    #[serde(with = "datetime_format")]
546    pub created_at: DateTime<Utc>,
547    pub dismissed: bool,
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    fn create_test_task(id: i64, name: &str, priority: Option<i32>) -> Task {
555        Task {
556            id,
557            parent_id: None,
558            name: name.to_string(),
559            spec: None,
560            status: "todo".to_string(),
561            complexity: None,
562            priority,
563            first_todo_at: None,
564            first_doing_at: None,
565            first_done_at: None,
566            active_form: None,
567            owner: "human".to_string(),
568            metadata: None,
569        }
570    }
571
572    #[test]
573    fn test_pick_next_response_focused_subtask() {
574        let task = create_test_task(1, "Test task", Some(5));
575        let response = PickNextResponse::focused_subtask(task.clone());
576
577        assert_eq!(response.suggestion_type, "FOCUSED_SUB_TASK");
578        assert!(response.task.is_some());
579        assert_eq!(response.task.unwrap().id, 1);
580        assert!(response.reason_code.is_none());
581        assert!(response.message.is_none());
582    }
583
584    #[test]
585    fn test_pick_next_response_top_level_task() {
586        let task = create_test_task(2, "Top level task", Some(3));
587        let response = PickNextResponse::top_level_task(task.clone());
588
589        assert_eq!(response.suggestion_type, "TOP_LEVEL_TASK");
590        assert!(response.task.is_some());
591        assert_eq!(response.task.unwrap().id, 2);
592        assert!(response.reason_code.is_none());
593        assert!(response.message.is_none());
594    }
595
596    #[test]
597    fn test_pick_next_response_no_tasks_in_project() {
598        let response = PickNextResponse::no_tasks_in_project();
599
600        assert_eq!(response.suggestion_type, "NONE");
601        assert!(response.task.is_none());
602        assert_eq!(response.reason_code.as_deref(), Some("NO_TASKS_IN_PROJECT"));
603        assert!(response.message.is_some());
604        assert!(response.message.unwrap().contains("No tasks found"));
605    }
606
607    #[test]
608    fn test_pick_next_response_all_tasks_completed() {
609        let response = PickNextResponse::all_tasks_completed();
610
611        assert_eq!(response.suggestion_type, "NONE");
612        assert!(response.task.is_none());
613        assert_eq!(response.reason_code.as_deref(), Some("ALL_TASKS_COMPLETED"));
614        assert!(response.message.is_some());
615        assert!(response.message.unwrap().contains("Project Complete"));
616    }
617
618    #[test]
619    fn test_pick_next_response_no_available_todos() {
620        let response = PickNextResponse::no_available_todos();
621
622        assert_eq!(response.suggestion_type, "NONE");
623        assert!(response.task.is_none());
624        assert_eq!(response.reason_code.as_deref(), Some("NO_AVAILABLE_TODOS"));
625        assert!(response.message.is_some());
626    }
627
628    #[test]
629    fn test_format_as_text_focused_subtask() {
630        let task = create_test_task(1, "Test task", Some(5));
631        let response = PickNextResponse::focused_subtask(task);
632        let text = response.format_as_text();
633
634        assert!(text.contains("Based on your current focus"));
635        assert!(text.contains("[ID: 1]"));
636        assert!(text.contains("[Priority: 5]"));
637        assert!(text.contains("Test task"));
638        assert!(text.contains("ie task start 1"));
639    }
640
641    #[test]
642    fn test_format_as_text_top_level_task() {
643        let task = create_test_task(2, "Top level task", None);
644        let response = PickNextResponse::top_level_task(task);
645        let text = response.format_as_text();
646
647        assert!(text.contains("Based on your current focus"));
648        assert!(text.contains("[ID: 2]"));
649        assert!(text.contains("[Priority: 0]")); // Default priority
650        assert!(text.contains("Top level task"));
651        assert!(text.contains("ie task start 2"));
652    }
653
654    #[test]
655    fn test_format_as_text_no_tasks_in_project() {
656        let response = PickNextResponse::no_tasks_in_project();
657        let text = response.format_as_text();
658
659        assert!(text.contains("[INFO]"));
660        assert!(text.contains("No tasks found"));
661        assert!(text.contains("ie task add"));
662        assert!(text.contains("--priority 1"));
663    }
664
665    #[test]
666    fn test_format_as_text_all_tasks_completed() {
667        let response = PickNextResponse::all_tasks_completed();
668        let text = response.format_as_text();
669
670        assert!(text.contains("[SUCCESS]"));
671        assert!(text.contains("Project Complete"));
672        assert!(text.contains("ie report --since 30d"));
673    }
674
675    #[test]
676    fn test_format_as_text_no_available_todos() {
677        let response = PickNextResponse::no_available_todos();
678        let text = response.format_as_text();
679
680        assert!(text.contains("[INFO]"));
681        assert!(text.contains("No immediate next task"));
682        assert!(text.contains("Possible reasons"));
683        assert!(text.contains("ie task find"));
684    }
685
686    #[test]
687    fn test_error_response_serialization() {
688        use crate::error::IntentError;
689
690        let error = IntentError::TaskNotFound(123);
691        let response = error.to_error_response();
692
693        assert_eq!(response.code, "TASK_NOT_FOUND");
694        assert!(response.error.contains("123"));
695    }
696
697    #[test]
698    fn test_next_step_suggestion_serialization() {
699        let suggestion = NextStepSuggestion::ParentIsReady {
700            message: "Test message".to_string(),
701            parent_task_id: 1,
702            parent_task_name: "Parent".to_string(),
703        };
704
705        let json = serde_json::to_string(&suggestion).unwrap();
706        assert!(json.contains("\"type\":\"PARENT_IS_READY\""));
707        assert!(json.contains("parent_task_id"));
708    }
709
710    #[test]
711    fn test_task_with_events_serialization() {
712        let task = create_test_task(1, "Test", Some(5));
713        let task_with_events = TaskWithEvents {
714            task,
715            events_summary: None,
716        };
717
718        let json = serde_json::to_string(&task_with_events).unwrap();
719        assert!(json.contains("\"id\":1"));
720        assert!(json.contains("\"name\":\"Test\""));
721        // events_summary should be skipped when None
722        assert!(!json.contains("events_summary"));
723    }
724
725    #[test]
726    fn test_report_summary_with_date_range() {
727        let from = Utc::now() - chrono::Duration::days(7);
728        let to = Utc::now();
729
730        let summary = ReportSummary {
731            total_tasks: 10,
732            tasks_by_status: StatusBreakdown {
733                todo: 5,
734                doing: 3,
735                done: 2,
736            },
737            total_events: 20,
738            date_range: Some(DateRange { from, to }),
739        };
740
741        let json = serde_json::to_string(&summary).unwrap();
742        assert!(json.contains("\"total_tasks\":10"));
743        assert!(json.contains("\"total_events\":20"));
744        assert!(json.contains("date_range"));
745    }
746}