use std::path::Path;
use anyhow::Context;
use crate::domain::model::event::EventLog;
use crate::infra::serde_support::RawEvent;
pub(crate) fn read_events_jsonl(index_path: &Path) -> anyhow::Result<Vec<RawEvent>> {
let sibling = index_path
.parent()
.expect("index.md has a parent dir")
.join("events.jsonl");
if !sibling.exists() {
return Ok(Vec::new());
}
let content = std::fs::read_to_string(&sibling)
.with_context(|| format!("reading {}", sibling.display()))?;
parse_events_jsonl(&content).with_context(|| format!("parsing {}", sibling.display()))
}
pub(crate) fn write_events_jsonl_to(record_dir: &Path, events: &EventLog) -> anyhow::Result<()> {
let content = events_to_jsonl(events)?;
let file_path = record_dir.join("events.jsonl");
let tmp_path = record_dir.join("events.jsonl.tmp");
std::fs::write(&tmp_path, &content)
.with_context(|| format!("writing events to {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &file_path)
.with_context(|| format!("renaming {} to {}", tmp_path.display(), file_path.display()))?;
Ok(())
}
pub(crate) fn events_to_jsonl(events: &EventLog) -> anyhow::Result<String> {
let mut out = String::new();
for event in events.iter() {
out.push_str(&serde_json::to_string(event)?);
out.push('\n');
}
Ok(out)
}
pub(crate) fn parse_events_jsonl(content: &str) -> anyhow::Result<Vec<RawEvent>> {
let mut events = Vec::new();
for (idx, line) in content.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let event: RawEvent = serde_json::from_str(line)
.with_context(|| format!("malformed JSONL at line {}", idx + 1))?;
events.push(event);
}
Ok(events)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::event::{Event, EventAction, EventLog, State};
use crate::domain::model::temporal::timestamp::Timestamp;
use crate::infra::serde_support::RawEventAction;
fn event(ts: &str, action: EventAction) -> Event {
Event {
timestamp: Timestamp::new(ts).unwrap(),
action,
}
}
fn state(name: &str) -> State {
State::new(name).unwrap()
}
#[test]
fn jsonl_writer_emits_one_line_per_event_with_trailing_newline() {
let mut log = EventLog::new();
log.push(event(
"2026-06-04T08:00:00Z",
EventAction::Created {
state: state("open"),
},
));
log.push(event(
"2026-06-04T09:00:00Z",
EventAction::StatusChanged {
from: state("open"),
to: state("closed"),
},
));
let jsonl = events_to_jsonl(&log).unwrap();
let lines: Vec<&str> = jsonl.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains(r#""timestamp":"2026-06-04T08:00:00Z""#));
assert!(lines[0].contains(r#""action":{"name":"created","status":"open"}"#));
assert!(
lines[1].contains(r#""action":{"name":"status_changed","from":"open","to":"closed"}"#)
);
assert!(jsonl.ends_with('\n'));
}
#[test]
fn jsonl_writer_emits_empty_string_on_empty_log() {
assert_eq!(events_to_jsonl(&EventLog::new()).unwrap(), "");
}
#[test]
fn returns_empty_on_empty_content() {
assert!(parse_events_jsonl("").unwrap().is_empty());
}
#[test]
fn skips_blank_lines() {
let jsonl = "\n\
{\"timestamp\":\"2026-06-04T08:00:00Z\",\"action\":{\"name\":\"created\",\"status\":\"open\"}}\n\
\n";
let events = parse_events_jsonl(jsonl).expect("one valid line surrounded by blanks");
assert_eq!(events.len(), 1);
}
#[test]
fn malformed_line_carries_one_based_line_number() {
let jsonl = "{\"timestamp\":\"2026-06-04T08:00:00Z\",\"action\":{\"name\":\"created\",\"status\":\"open\"}}\n\
not-json\n";
let err = parse_events_jsonl(jsonl).expect_err("second line is not JSON");
let msg = format!("{err:#}");
assert!(
msg.contains("line 2"),
"error must cite the 1-based line, got:\n{msg}"
);
}
#[test]
fn parses_one_event_per_line_in_document_order() {
let jsonl = "{\"timestamp\":\"2026-06-04T08:00:00Z\",\"action\":{\"name\":\"created\",\"status\":\"open\"}}\n\
{\"timestamp\":\"2026-06-04T09:00:00Z\",\"action\":{\"name\":\"status_changed\",\"from\":\"open\",\"to\":\"doing\"}}\n";
let events = parse_events_jsonl(jsonl).expect("two valid lines");
assert_eq!(events.len(), 2);
assert!(matches!(events[0].action, RawEventAction::Created { .. }));
assert!(matches!(
events[1].action,
RawEventAction::StatusChanged { .. }
));
}
}