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/// Canonical file name for a session-isolated preflight audit (#100).
58///
59/// Used by `shipper preflight --preflight-only` to keep a fresh
60/// finishability audit from appending into the authoritative
61/// `events.jsonl` log, preserving events-as-truth for the publish
62/// flow while still producing an auditable JSONL trace for the
63/// standalone preflight run.
64pub const PREFLIGHT_ONLY_EVENTS_FILE_PREFIX: &str = "preflight-only-";
65pub const PREFLIGHT_ONLY_EVENTS_FILE_SUFFIX: &str = ".events.jsonl";
66
67/// Get the events file path for a state directory.
68///
69/// The returned value is always `state_dir/events.jsonl`.
70pub fn events_path(state_dir: &Path) -> PathBuf {
71    state_dir.join(EVENTS_FILE)
72}
73
74/// Get the session-isolated preflight audit events file path (#100).
75///
76/// Used by `shipper preflight --preflight-only` so that a fresh audit
77/// never appends to the authoritative `events.jsonl` log. Each
78/// invocation writes to its own session-scoped JSONL file, keeping
79/// standalone audits isolated from both publish history and one another.
80pub fn preflight_only_events_path(state_dir: &Path, session_id: &str) -> PathBuf {
81    state_dir.join(format!(
82        "{PREFLIGHT_ONLY_EVENTS_FILE_PREFIX}{session_id}{PREFLIGHT_ONLY_EVENTS_FILE_SUFFIX}"
83    ))
84}
85
86/// Return all preflight-only event sidecars in lexical order.
87pub fn preflight_only_events_paths(state_dir: &Path) -> Result<Vec<PathBuf>> {
88    let mut paths = Vec::new();
89
90    if !state_dir.exists() {
91        return Ok(paths);
92    }
93
94    for entry in fs::read_dir(state_dir)
95        .with_context(|| format!("failed to read state dir {}", state_dir.display()))?
96    {
97        let entry =
98            entry.with_context(|| format!("failed to read entry in {}", state_dir.display()))?;
99        let file_type = entry
100            .file_type()
101            .with_context(|| format!("failed to stat {}", entry.path().display()))?;
102
103        if !file_type.is_file() {
104            continue;
105        }
106
107        let Some(file_name) = entry.file_name().to_str().map(str::to_owned) else {
108            continue;
109        };
110
111        if file_name.starts_with(PREFLIGHT_ONLY_EVENTS_FILE_PREFIX)
112            && file_name.ends_with(PREFLIGHT_ONLY_EVENTS_FILE_SUFFIX)
113        {
114            paths.push(entry.path());
115        }
116    }
117
118    paths.sort();
119    Ok(paths)
120}
121
122/// Append-only event log for publish operations.
123///
124/// Events are stored in-memory in insertion order.
125#[derive(Debug, Default)]
126pub struct EventLog {
127    events: Vec<PublishEvent>,
128}
129
130impl EventLog {
131    /// Create a new empty event log.
132    pub fn new() -> Self {
133        Self { events: Vec::new() }
134    }
135
136    /// Record a new event.
137    ///
138    /// Added events are appended and remain in order.
139    pub fn record(&mut self, event: PublishEvent) {
140        self.events.push(event);
141    }
142
143    /// Write all recorded events to a file in JSONL format.
144    ///
145    /// The file is opened in append mode and existing contents are preserved.
146    pub fn write_to_file(&self, path: &Path) -> Result<()> {
147        if let Some(parent) = path.parent() {
148            fs::create_dir_all(parent)
149                .with_context(|| format!("failed to create events dir {}", parent.display()))?;
150        }
151
152        // Append mode: open file, write new events
153        let file = OpenOptions::new()
154            .create(true)
155            .append(true)
156            .open(path)
157            .with_context(|| format!("failed to open events file {}", path.display()))?;
158
159        let mut writer = std::io::BufWriter::new(file);
160
161        self.write_events_to(&mut writer)?;
162
163        writer.flush().context("failed to flush events file")?;
164
165        Ok(())
166    }
167
168    fn write_events_to<W: Write>(&self, writer: &mut W) -> Result<()> {
169        for event in &self.events {
170            serde_json::to_writer(&mut *writer, event)
171                .context("failed to serialize event to JSON")?;
172            writer
173                .write_all(b"\n")
174                .context("failed to write event line")?;
175        }
176
177        Ok(())
178    }
179
180    /// Read all events from a JSONL file.
181    ///
182    /// Returns an empty log when the file does not exist.
183    pub fn read_from_file(path: &Path) -> Result<Self> {
184        if !path.exists() {
185            return Ok(Self::new());
186        }
187
188        let file = File::open(path)
189            .with_context(|| format!("failed to open events file {}", path.display()))?;
190
191        let reader = BufReader::new(file);
192        let mut events = Vec::new();
193
194        for line in reader.lines() {
195            let line = line.with_context(|| {
196                format!("failed to read line from events file {}", path.display())
197            })?;
198            let event: PublishEvent = serde_json::from_str(&line)
199                .with_context(|| format!("failed to parse event JSON from line: {}", line))?;
200            events.push(event);
201        }
202
203        Ok(Self { events })
204    }
205
206    /// Get all events for a specific package.
207    ///
208    /// Matching is exact against the `package` field.
209    pub fn events_for_package(&self, package: &str) -> Vec<&PublishEvent> {
210        self.events
211            .iter()
212            .filter(|e| e.package == package)
213            .collect()
214    }
215
216    /// Get all recorded events.
217    pub fn all_events(&self) -> &[PublishEvent] {
218        &self.events
219    }
220
221    /// Clear all recorded events from memory.
222    pub fn clear(&mut self) {
223        self.events.clear();
224    }
225
226    /// Get the number of recorded events.
227    pub fn len(&self) -> usize {
228        self.events.len()
229    }
230
231    /// Check if the log is empty.
232    pub fn is_empty(&self) -> bool {
233        self.events.is_empty()
234    }
235}