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