Skip to main content

batuta/playbook/
eventlog.rs

1//! Append-only JSONL event log for playbook execution (PB-005)
2//!
3//! Each playbook run appends events to a `.events.jsonl` file alongside the
4//! playbook YAML. Events are timestamped and tagged with a run ID.
5
6use super::types::{PipelineEvent, TimestampedEvent};
7use anyhow::{Context, Result};
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11/// Derive the event log path from a playbook path: `.yaml` → `.events.jsonl`
12pub fn event_log_path(playbook_path: &Path) -> PathBuf {
13    let stem = playbook_path.file_stem().unwrap_or_default().to_string_lossy();
14    playbook_path.with_file_name(format!("{}.events.jsonl", stem))
15}
16
17/// Generate a unique run ID: `"r-{short_hex}"`
18pub fn generate_run_id() -> String {
19    use std::time::{SystemTime, UNIX_EPOCH};
20
21    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
22
23    // Combine timestamp nanos with process ID for uniqueness
24    let seed = now.as_nanos() ^ (std::process::id() as u128);
25    format!("r-{:012x}", seed & 0xFFFF_FFFF_FFFF)
26}
27
28/// Get the current UTC timestamp in ISO 8601 format
29pub fn now_iso8601() -> String {
30    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
31}
32
33/// Append a pipeline event to the event log
34pub fn append_event(playbook_path: &Path, event: PipelineEvent) -> Result<()> {
35    let path = event_log_path(playbook_path);
36    let timestamped = TimestampedEvent { ts: now_iso8601(), event };
37
38    let json = serde_json::to_string(&timestamped).context("failed to serialize event")?;
39
40    let mut file = std::fs::OpenOptions::new()
41        .create(true)
42        .append(true)
43        .open(&path)
44        .with_context(|| format!("failed to open event log: {}", path.display()))?;
45
46    writeln!(file, "{}", json).context("failed to write event")?;
47
48    Ok(())
49}
50
51#[cfg(test)]
52#[allow(non_snake_case)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn test_PB005_event_log_path() {
58        let path = event_log_path(Path::new("/tmp/pipeline.yaml"));
59        assert_eq!(path, PathBuf::from("/tmp/pipeline.events.jsonl"));
60    }
61
62    #[test]
63    fn test_PB005_generate_run_id_format() {
64        let id = generate_run_id();
65        assert!(id.starts_with("r-"));
66        assert!(id.len() > 2);
67    }
68
69    #[test]
70    fn test_PB005_generate_run_id_unique() {
71        let id1 = generate_run_id();
72        // Small sleep to ensure different nanos
73        std::thread::sleep(std::time::Duration::from_millis(1));
74        let id2 = generate_run_id();
75        assert_ne!(id1, id2);
76    }
77
78    #[test]
79    fn test_PB005_append_event() {
80        let dir = tempfile::tempdir().expect("tempdir creation failed");
81        let playbook_path = dir.path().join("test.yaml");
82
83        append_event(
84            &playbook_path,
85            PipelineEvent::RunStarted {
86                playbook: "test".to_string(),
87                run_id: "r-abc123".to_string(),
88                batuta_version: "0.6.5".to_string(),
89            },
90        )
91        .expect("unexpected failure");
92
93        append_event(
94            &playbook_path,
95            PipelineEvent::StageCached {
96                stage: "hello".to_string(),
97                cache_key: "blake3:key".to_string(),
98                reason: "cache_key matches".to_string(),
99            },
100        )
101        .expect("unexpected failure");
102
103        let log_path = event_log_path(&playbook_path);
104        let content = std::fs::read_to_string(&log_path).expect("fs read failed");
105        let lines: Vec<&str> = content.lines().collect();
106        assert_eq!(lines.len(), 2);
107
108        // Parse each line as valid JSON
109        let event1: TimestampedEvent =
110            serde_json::from_str(lines[0]).expect("json deserialize failed");
111        assert!(matches!(event1.event, PipelineEvent::RunStarted { .. }));
112
113        let event2: TimestampedEvent =
114            serde_json::from_str(lines[1]).expect("json deserialize failed");
115        assert!(matches!(event2.event, PipelineEvent::StageCached { .. }));
116    }
117
118    #[test]
119    fn test_PB005_now_iso8601_format() {
120        let ts = now_iso8601();
121        // Should match ISO 8601 pattern
122        assert!(ts.contains('T'));
123        assert!(ts.ends_with('Z'));
124    }
125}