Skip to main content

shipper_core/state/events/
mod.rs

1//! Append-only JSONL event log for publish operations.
2//!
3//! **Layer:** state (layer 3).
4//!
5//! Absorbed from the former `shipper-events` microcrate (Phase 2 decrating).
6//! The [`EventLog`] type stores publish lifecycle events in memory and can
7//! persist them to disk as newline-delimited JSON (`.jsonl`).
8//!
9//! # JSONL format
10//!
11//! Each event is serialized as one JSON object per line using
12//! [`shipper_types::PublishEvent`]. The output appends new events to existing
13//! logs.
14//!
15//! The canonical file name for the event log is [`EVENTS_FILE`], resolved from
16//! a state directory by [`events_path`].
17//!
18//! # Examples
19//!
20//! ## Append events and persist
21//! ```ignore
22//! use chrono::Utc;
23//! use shipper::state::events::{EventLog, events_path};
24//! use shipper_types::{EventType, PublishEvent};
25//! use std::path::Path;
26//!
27//! let mut log = EventLog::new();
28//! let event = PublishEvent {
29//!     timestamp: Utc::now(),
30//!     event_type: EventType::PackageStarted {
31//!         name: "my-crate".to_string(),
32//!         version: "1.0.0".to_string(),
33//!     },
34//!     package: "my-crate@1.0.0".to_string(),
35//! };
36//!
37//! log.record(event);
38//! let path = events_path(Path::new(".shipper"));
39//! log.write_to_file(&path).expect("write events");
40//! ```
41
42use std::fs::{self, File, OpenOptions};
43use std::io::{BufRead, BufReader, Write};
44use std::path::{Path, PathBuf};
45
46use anyhow::{Context, Result};
47use shipper_types::PublishEvent;
48
49#[cfg(test)]
50mod proptests;
51#[cfg(test)]
52mod tests;
53
54/// Canonical event file name.
55pub const EVENTS_FILE: &str = "events.jsonl";
56
57/// Get the events file path for a state directory.
58///
59/// The returned value is always `state_dir/events.jsonl`.
60pub fn events_path(state_dir: &Path) -> PathBuf {
61    state_dir.join(EVENTS_FILE)
62}
63
64/// Append-only event log for publish operations.
65///
66/// Events are stored in-memory in insertion order.
67#[derive(Debug, Default)]
68pub struct EventLog {
69    events: Vec<PublishEvent>,
70}
71
72impl EventLog {
73    /// Create a new empty event log.
74    pub fn new() -> Self {
75        Self { events: Vec::new() }
76    }
77
78    /// Record a new event.
79    ///
80    /// Added events are appended and remain in order.
81    pub fn record(&mut self, event: PublishEvent) {
82        self.events.push(event);
83    }
84
85    /// Write all recorded events to a file in JSONL format.
86    ///
87    /// The file is opened in append mode and existing contents are preserved.
88    pub fn write_to_file(&self, path: &Path) -> Result<()> {
89        if let Some(parent) = path.parent() {
90            fs::create_dir_all(parent)
91                .with_context(|| format!("failed to create events dir {}", parent.display()))?;
92        }
93
94        // Append mode: open file, write new events
95        let file = OpenOptions::new()
96            .create(true)
97            .append(true)
98            .open(path)
99            .with_context(|| format!("failed to open events file {}", path.display()))?;
100
101        let mut writer = std::io::BufWriter::new(file);
102
103        for event in &self.events {
104            let line = serde_json::to_string(event).context("failed to serialize event to JSON")?;
105            writeln!(writer, "{}", line).context("failed to write event line")?;
106        }
107
108        writer.flush().context("failed to flush events file")?;
109
110        Ok(())
111    }
112
113    /// Read all events from a JSONL file.
114    ///
115    /// Returns an empty log when the file does not exist.
116    pub fn read_from_file(path: &Path) -> Result<Self> {
117        if !path.exists() {
118            return Ok(Self::new());
119        }
120
121        let file = File::open(path)
122            .with_context(|| format!("failed to open events file {}", path.display()))?;
123
124        let reader = BufReader::new(file);
125        let mut events = Vec::new();
126
127        for line in reader.lines() {
128            let line = line.with_context(|| {
129                format!("failed to read line from events file {}", path.display())
130            })?;
131            let event: PublishEvent = serde_json::from_str(&line)
132                .with_context(|| format!("failed to parse event JSON from line: {}", line))?;
133            events.push(event);
134        }
135
136        Ok(Self { events })
137    }
138
139    /// Get all events for a specific package.
140    ///
141    /// Matching is exact against the `package` field.
142    pub fn events_for_package(&self, package: &str) -> Vec<&PublishEvent> {
143        self.events
144            .iter()
145            .filter(|e| e.package == package)
146            .collect()
147    }
148
149    /// Get all recorded events.
150    pub fn all_events(&self) -> &[PublishEvent] {
151        &self.events
152    }
153
154    /// Clear all recorded events from memory.
155    pub fn clear(&mut self) {
156        self.events.clear();
157    }
158
159    /// Get the number of recorded events.
160    pub fn len(&self) -> usize {
161        self.events.len()
162    }
163
164    /// Check if the log is empty.
165    pub fn is_empty(&self) -> bool {
166        self.events.is_empty()
167    }
168}