cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Read the JSONL event-log companion (`events.jsonl`) sitting next to an
//! `index.md`. The on-disk format is one JSON object per line, mirroring the
//! `Event` model 1-for-1. ADR-01HMR0G23XH0N.
//!
//! The `RawEvent` deserialiser is data-format agnostic (it visits a map of
//! keys), so JSON lines parse through the same code path that used to read
//! the YAML `events:` block.

use std::path::Path;

use anyhow::Context;

use crate::domain::model::event::EventLog;
use crate::infra::serde_support::RawEvent;

/// Read every event from `events.jsonl` next to `index_path`. Returns an
/// empty vec when the companion file is absent — caller decides whether
/// that is an error (`cartu check` does in v7) or a legitimate skip
/// (pre-v7 records still carry their events inline in the frontmatter).
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()))
}

/// Write `events.jsonl` next to `index.md` in `record_dir`. Atomic — goes
/// through a sibling `.tmp` + POSIX rename, same pattern as `save_index`.
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(())
}

/// Pure transform: serialise an [`EventLog`] as JSONL — one event per line,
/// trailing newline included. Returns an empty string when the log is empty
/// so the caller can still write a zero-byte companion (the "every entry
/// has a sibling" invariant holds uniformly).
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)
}

/// Pure transform: one JSON object per non-blank line, in document order.
/// A malformed line aborts with the 1-based line number in the error chain.
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 { .. }
        ));
    }
}