Skip to main content

cuenv_events/
event.rs

1//! Event type definitions for structured cuenv events.
2//!
3//! This module defines the core event types that flow through the cuenv event system.
4//! Events are categorized by domain (Task, CI, Command, etc.) and include rich metadata.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10/// A structured cuenv event with full metadata.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct CuenvEvent {
13    /// Unique event identifier.
14    pub id: Uuid,
15    /// Correlation ID for request tracing across operations.
16    pub correlation_id: Uuid,
17    /// When the event occurred.
18    pub timestamp: DateTime<Utc>,
19    /// Source information for the event.
20    pub source: EventSource,
21    /// The event category and data.
22    pub category: EventCategory,
23}
24
25impl CuenvEvent {
26    /// Create a new event with the given category.
27    #[must_use]
28    pub fn new(correlation_id: Uuid, source: EventSource, category: EventCategory) -> Self {
29        Self {
30            id: Uuid::new_v4(),
31            correlation_id,
32            timestamp: Utc::now(),
33            source,
34            category,
35        }
36    }
37}
38
39/// Source information for an event.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct EventSource {
42    /// The tracing target (e.g., "`cuenv::task`", "`cuenv::ci`").
43    pub target: String,
44    /// Source file path, if available.
45    pub file: Option<String>,
46    /// Source line number, if available.
47    pub line: Option<u32>,
48}
49
50impl EventSource {
51    /// Create a new event source with just a target.
52    #[must_use]
53    pub fn new(target: impl Into<String>) -> Self {
54        Self {
55            target: target.into(),
56            file: None,
57            line: None,
58        }
59    }
60
61    /// Create a new event source with file and line information.
62    #[must_use]
63    pub fn with_location(target: impl Into<String>, file: impl Into<String>, line: u32) -> Self {
64        Self {
65            target: target.into(),
66            file: Some(file.into()),
67            line: Some(line),
68        }
69    }
70}
71
72/// Event categories organized by domain.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(tag = "type", content = "data")]
75pub enum EventCategory {
76    /// Task execution lifecycle events.
77    Task(TaskEvent),
78    /// CI pipeline events.
79    Ci(CiEvent),
80    /// Command lifecycle events.
81    Command(CommandEvent),
82    /// User interaction events.
83    Interactive(InteractiveEvent),
84    /// System/supervisor events.
85    System(SystemEvent),
86    /// Generic output events (for migration and compatibility).
87    Output(OutputEvent),
88}
89
90/// Task execution lifecycle events.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(tag = "event", content = "data")]
93pub enum TaskEvent {
94    /// Task execution started.
95    Started {
96        /// Task name.
97        name: String,
98        /// Command being executed.
99        command: String,
100        /// Whether this is a hermetic execution.
101        hermetic: bool,
102    },
103    /// Task cache hit - using cached result.
104    CacheHit {
105        /// Task name.
106        name: String,
107        /// Cache key that matched.
108        cache_key: String,
109    },
110    /// Task cache miss - will execute.
111    CacheMiss {
112        /// Task name.
113        name: String,
114    },
115    /// Task produced output.
116    Output {
117        /// Task name.
118        name: String,
119        /// Output stream.
120        stream: Stream,
121        /// Output content.
122        content: String,
123    },
124    /// Task execution completed.
125    Completed {
126        /// Task name.
127        name: String,
128        /// Whether the task succeeded.
129        success: bool,
130        /// Exit code, if available.
131        exit_code: Option<i32>,
132        /// Duration in milliseconds.
133        duration_ms: u64,
134    },
135    /// Task group execution started.
136    GroupStarted {
137        /// Group name/prefix.
138        name: String,
139        /// Whether tasks run sequentially.
140        sequential: bool,
141        /// Number of tasks in the group.
142        task_count: usize,
143    },
144    /// Task group execution completed.
145    GroupCompleted {
146        /// Group name/prefix.
147        name: String,
148        /// Whether all tasks succeeded.
149        success: bool,
150        /// Duration in milliseconds.
151        duration_ms: u64,
152    },
153}
154
155/// CI pipeline events.
156#[derive(Debug, Clone, Serialize, Deserialize)]
157#[serde(tag = "event", content = "data")]
158pub enum CiEvent {
159    /// CI context detected.
160    ContextDetected {
161        /// CI provider name.
162        provider: String,
163        /// Event type (push, `pull_request`, etc.).
164        event_type: String,
165        /// Git ref name.
166        ref_name: String,
167    },
168    /// Changed files found.
169    ChangedFilesFound {
170        /// Number of changed files.
171        count: usize,
172    },
173    /// Projects discovered.
174    ProjectsDiscovered {
175        /// Number of projects found.
176        count: usize,
177    },
178    /// Project skipped (no affected tasks).
179    ProjectSkipped {
180        /// Project path.
181        path: String,
182        /// Reason for skipping.
183        reason: String,
184    },
185    /// Task executing within CI.
186    TaskExecuting {
187        /// Project path.
188        project: String,
189        /// Task name.
190        task: String,
191    },
192    /// Task result within CI.
193    TaskResult {
194        /// Project path.
195        project: String,
196        /// Task name.
197        task: String,
198        /// Whether the task succeeded.
199        success: bool,
200        /// Error message, if failed.
201        error: Option<String>,
202    },
203    /// CI report generated.
204    ReportGenerated {
205        /// Report file path.
206        path: String,
207    },
208}
209
210/// Command lifecycle events.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(tag = "event", content = "data")]
213pub enum CommandEvent {
214    /// Command started.
215    Started {
216        /// Command name.
217        command: String,
218        /// Command arguments.
219        args: Vec<String>,
220    },
221    /// Command progress update.
222    Progress {
223        /// Command name.
224        command: String,
225        /// Progress percentage (0.0 to 1.0).
226        progress: f32,
227        /// Progress message.
228        message: String,
229    },
230    /// Command completed.
231    Completed {
232        /// Command name.
233        command: String,
234        /// Whether the command succeeded.
235        success: bool,
236        /// Duration in milliseconds.
237        duration_ms: u64,
238    },
239}
240
241/// User interaction events.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243#[serde(tag = "event", content = "data")]
244pub enum InteractiveEvent {
245    /// Prompt requested from user.
246    PromptRequested {
247        /// Unique prompt identifier.
248        prompt_id: String,
249        /// The prompt message.
250        message: String,
251        /// Available options.
252        options: Vec<String>,
253    },
254    /// Prompt resolved with user response.
255    PromptResolved {
256        /// Prompt identifier.
257        prompt_id: String,
258        /// User's response.
259        response: String,
260    },
261    /// Wait/progress indicator.
262    WaitProgress {
263        /// What we're waiting for.
264        target: String,
265        /// Elapsed seconds.
266        elapsed_secs: u64,
267    },
268}
269
270/// System/supervisor events.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(tag = "event", content = "data")]
273pub enum SystemEvent {
274    /// Supervisor log message.
275    SupervisorLog {
276        /// Log tag/category.
277        tag: String,
278        /// Log message.
279        message: String,
280    },
281    /// System shutdown.
282    Shutdown,
283}
284
285/// Generic output events for migration and compatibility.
286#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(tag = "event", content = "data")]
288pub enum OutputEvent {
289    /// Standard output.
290    Stdout {
291        /// Content to output.
292        content: String,
293    },
294    /// Standard error.
295    Stderr {
296        /// Content to output.
297        content: String,
298    },
299}
300
301/// Output stream identifier.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
303pub enum Stream {
304    /// Standard output.
305    Stdout,
306    /// Standard error.
307    Stderr,
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_event_creation() {
316        let event = CuenvEvent::new(
317            Uuid::new_v4(),
318            EventSource::new("cuenv::test"),
319            EventCategory::Output(OutputEvent::Stdout {
320                content: "test".to_string(),
321            }),
322        );
323
324        assert!(!event.id.is_nil());
325        assert_eq!(event.source.target, "cuenv::test");
326    }
327
328    #[test]
329    fn test_event_serialization() {
330        let event = CuenvEvent::new(
331            Uuid::new_v4(),
332            EventSource::new("cuenv::task"),
333            EventCategory::Task(TaskEvent::Started {
334                name: "build".to_string(),
335                command: "cargo build".to_string(),
336                hermetic: true,
337            }),
338        );
339
340        let json = serde_json::to_string(&event).unwrap();
341        assert!(json.contains("cuenv::task"));
342        assert!(json.contains("build"));
343
344        let parsed: CuenvEvent = serde_json::from_str(&json).unwrap();
345        assert_eq!(parsed.id, event.id);
346    }
347
348    #[test]
349    fn test_event_source_with_location() {
350        let source = EventSource::with_location("cuenv::task", "src/main.rs", 42);
351        assert_eq!(source.target, "cuenv::task");
352        assert_eq!(source.file, Some("src/main.rs".to_string()));
353        assert_eq!(source.line, Some(42));
354    }
355
356    #[test]
357    fn test_event_source_new() {
358        let source = EventSource::new("cuenv::ci");
359        assert_eq!(source.target, "cuenv::ci");
360        assert!(source.file.is_none());
361        assert!(source.line.is_none());
362    }
363
364    #[test]
365    fn test_task_event_cache_hit() {
366        let event = TaskEvent::CacheHit {
367            name: "test".to_string(),
368            cache_key: "abc123".to_string(),
369        };
370        let json = serde_json::to_string(&event).unwrap();
371        assert!(json.contains("CacheHit"));
372        assert!(json.contains("abc123"));
373    }
374
375    #[test]
376    fn test_task_event_cache_miss() {
377        let event = TaskEvent::CacheMiss {
378            name: "test".to_string(),
379        };
380        let json = serde_json::to_string(&event).unwrap();
381        assert!(json.contains("CacheMiss"));
382    }
383
384    #[test]
385    fn test_task_event_output() {
386        let event = TaskEvent::Output {
387            name: "build".to_string(),
388            stream: Stream::Stdout,
389            content: "compiling...".to_string(),
390        };
391        let json = serde_json::to_string(&event).unwrap();
392        assert!(json.contains("Output"));
393        assert!(json.contains("Stdout"));
394    }
395
396    #[test]
397    fn test_task_event_completed() {
398        let event = TaskEvent::Completed {
399            name: "build".to_string(),
400            success: true,
401            exit_code: Some(0),
402            duration_ms: 1500,
403        };
404        let json = serde_json::to_string(&event).unwrap();
405        assert!(json.contains("Completed"));
406        assert!(json.contains("1500"));
407    }
408
409    #[test]
410    fn test_task_event_group_started() {
411        let event = TaskEvent::GroupStarted {
412            name: "tests".to_string(),
413            sequential: false,
414            task_count: 5,
415        };
416        let json = serde_json::to_string(&event).unwrap();
417        assert!(json.contains("GroupStarted"));
418        assert!(json.contains('5'));
419    }
420
421    #[test]
422    fn test_task_event_group_completed() {
423        let event = TaskEvent::GroupCompleted {
424            name: "tests".to_string(),
425            success: true,
426            duration_ms: 3000,
427        };
428        let json = serde_json::to_string(&event).unwrap();
429        assert!(json.contains("GroupCompleted"));
430    }
431
432    #[test]
433    fn test_ci_event_context_detected() {
434        let event = CiEvent::ContextDetected {
435            provider: "github".to_string(),
436            event_type: "push".to_string(),
437            ref_name: "main".to_string(),
438        };
439        let json = serde_json::to_string(&event).unwrap();
440        assert!(json.contains("ContextDetected"));
441        assert!(json.contains("github"));
442    }
443
444    #[test]
445    fn test_ci_event_changed_files_found() {
446        let event = CiEvent::ChangedFilesFound { count: 10 };
447        let json = serde_json::to_string(&event).unwrap();
448        assert!(json.contains("ChangedFilesFound"));
449        assert!(json.contains("10"));
450    }
451
452    #[test]
453    fn test_ci_event_projects_discovered() {
454        let event = CiEvent::ProjectsDiscovered { count: 3 };
455        let json = serde_json::to_string(&event).unwrap();
456        assert!(json.contains("ProjectsDiscovered"));
457    }
458
459    #[test]
460    fn test_ci_event_project_skipped() {
461        let event = CiEvent::ProjectSkipped {
462            path: "/project".to_string(),
463            reason: "no changes".to_string(),
464        };
465        let json = serde_json::to_string(&event).unwrap();
466        assert!(json.contains("ProjectSkipped"));
467        assert!(json.contains("no changes"));
468    }
469
470    #[test]
471    fn test_ci_event_task_executing() {
472        let event = CiEvent::TaskExecuting {
473            project: "/app".to_string(),
474            task: "build".to_string(),
475        };
476        let json = serde_json::to_string(&event).unwrap();
477        assert!(json.contains("TaskExecuting"));
478    }
479
480    #[test]
481    fn test_ci_event_task_result() {
482        let event = CiEvent::TaskResult {
483            project: "/app".to_string(),
484            task: "build".to_string(),
485            success: false,
486            error: Some("build failed".to_string()),
487        };
488        let json = serde_json::to_string(&event).unwrap();
489        assert!(json.contains("TaskResult"));
490        assert!(json.contains("build failed"));
491    }
492
493    #[test]
494    fn test_ci_event_report_generated() {
495        let event = CiEvent::ReportGenerated {
496            path: "/reports/ci.json".to_string(),
497        };
498        let json = serde_json::to_string(&event).unwrap();
499        assert!(json.contains("ReportGenerated"));
500    }
501
502    #[test]
503    fn test_command_event_started() {
504        let event = CommandEvent::Started {
505            command: "sync".to_string(),
506            args: vec!["--force".to_string()],
507        };
508        let json = serde_json::to_string(&event).unwrap();
509        assert!(json.contains("Started"));
510        assert!(json.contains("--force"));
511    }
512
513    #[test]
514    fn test_command_event_progress() {
515        let event = CommandEvent::Progress {
516            command: "sync".to_string(),
517            progress: 0.5,
518            message: "halfway there".to_string(),
519        };
520        let json = serde_json::to_string(&event).unwrap();
521        assert!(json.contains("Progress"));
522        assert!(json.contains("0.5"));
523    }
524
525    #[test]
526    fn test_command_event_completed() {
527        let event = CommandEvent::Completed {
528            command: "sync".to_string(),
529            success: true,
530            duration_ms: 500,
531        };
532        let json = serde_json::to_string(&event).unwrap();
533        assert!(json.contains("Completed"));
534    }
535
536    #[test]
537    fn test_interactive_event_prompt_requested() {
538        let event = InteractiveEvent::PromptRequested {
539            prompt_id: "p1".to_string(),
540            message: "Choose an option".to_string(),
541            options: vec!["a".to_string(), "b".to_string()],
542        };
543        let json = serde_json::to_string(&event).unwrap();
544        assert!(json.contains("PromptRequested"));
545        assert!(json.contains("Choose an option"));
546    }
547
548    #[test]
549    fn test_interactive_event_prompt_resolved() {
550        let event = InteractiveEvent::PromptResolved {
551            prompt_id: "p1".to_string(),
552            response: "a".to_string(),
553        };
554        let json = serde_json::to_string(&event).unwrap();
555        assert!(json.contains("PromptResolved"));
556    }
557
558    #[test]
559    fn test_interactive_event_wait_progress() {
560        let event = InteractiveEvent::WaitProgress {
561            target: "lock".to_string(),
562            elapsed_secs: 30,
563        };
564        let json = serde_json::to_string(&event).unwrap();
565        assert!(json.contains("WaitProgress"));
566        assert!(json.contains("30"));
567    }
568
569    #[test]
570    fn test_system_event_supervisor_log() {
571        let event = SystemEvent::SupervisorLog {
572            tag: "coordinator".to_string(),
573            message: "started".to_string(),
574        };
575        let json = serde_json::to_string(&event).unwrap();
576        assert!(json.contains("SupervisorLog"));
577    }
578
579    #[test]
580    fn test_system_event_shutdown() {
581        let event = SystemEvent::Shutdown;
582        let json = serde_json::to_string(&event).unwrap();
583        assert!(json.contains("Shutdown"));
584    }
585
586    #[test]
587    fn test_output_event_stdout() {
588        let event = OutputEvent::Stdout {
589            content: "hello".to_string(),
590        };
591        let json = serde_json::to_string(&event).unwrap();
592        assert!(json.contains("Stdout"));
593        assert!(json.contains("hello"));
594    }
595
596    #[test]
597    fn test_output_event_stderr() {
598        let event = OutputEvent::Stderr {
599            content: "error".to_string(),
600        };
601        let json = serde_json::to_string(&event).unwrap();
602        assert!(json.contains("Stderr"));
603    }
604
605    #[test]
606    fn test_stream_enum() {
607        assert_eq!(Stream::Stdout, Stream::Stdout);
608        assert_ne!(Stream::Stdout, Stream::Stderr);
609
610        let stdout_json = serde_json::to_string(&Stream::Stdout).unwrap();
611        let stderr_json = serde_json::to_string(&Stream::Stderr).unwrap();
612
613        assert!(stdout_json.contains("Stdout"));
614        assert!(stderr_json.contains("Stderr"));
615    }
616
617    #[test]
618    fn test_event_category_all_variants() {
619        let categories = vec![
620            EventCategory::Task(TaskEvent::CacheMiss {
621                name: "test".to_string(),
622            }),
623            EventCategory::Ci(CiEvent::ProjectsDiscovered { count: 1 }),
624            EventCategory::Command(CommandEvent::Started {
625                command: "sync".to_string(),
626                args: vec![],
627            }),
628            EventCategory::Interactive(InteractiveEvent::WaitProgress {
629                target: "lock".to_string(),
630                elapsed_secs: 0,
631            }),
632            EventCategory::System(SystemEvent::Shutdown),
633            EventCategory::Output(OutputEvent::Stdout {
634                content: "out".to_string(),
635            }),
636        ];
637
638        for cat in categories {
639            let json = serde_json::to_string(&cat).unwrap();
640            let parsed: EventCategory = serde_json::from_str(&json).unwrap();
641            // Verify round-trip works
642            let json2 = serde_json::to_string(&parsed).unwrap();
643            assert_eq!(json, json2);
644        }
645    }
646
647    #[test]
648    fn test_cuenv_event_clone() {
649        let event = CuenvEvent::new(
650            Uuid::new_v4(),
651            EventSource::new("cuenv::test"),
652            EventCategory::System(SystemEvent::Shutdown),
653        );
654        let cloned = event.clone();
655        assert_eq!(event.id, cloned.id);
656        assert_eq!(event.correlation_id, cloned.correlation_id);
657    }
658
659    #[test]
660    fn test_cuenv_event_debug() {
661        let event = CuenvEvent::new(
662            Uuid::new_v4(),
663            EventSource::new("cuenv::test"),
664            EventCategory::System(SystemEvent::Shutdown),
665        );
666        let debug_str = format!("{event:?}");
667        assert!(debug_str.contains("CuenvEvent"));
668    }
669}