Skip to main content

shiplog_render_json/
lib.rs

1use anyhow::{Context, Result};
2use shiplog_schema::coverage::CoverageManifest;
3use shiplog_schema::event::EventEnvelope;
4use std::io::Write;
5use std::path::Path;
6
7/// Write canonical events to JSONL.
8///
9/// JSONL is the right primitive:
10/// - line-delimited, append-friendly
11/// - diff-friendly
12/// - can be streamed
13pub fn write_events_jsonl(path: &Path, events: &[EventEnvelope]) -> Result<()> {
14    let mut f = std::fs::File::create(path).with_context(|| format!("create {path:?}"))?;
15    for ev in events {
16        let line = serde_json::to_string(ev).context("serialize event")?;
17        f.write_all(line.as_bytes())?;
18        f.write_all(b"\n")?;
19    }
20    Ok(())
21}
22
23pub fn write_coverage_manifest(path: &Path, cov: &CoverageManifest) -> Result<()> {
24    let text = serde_json::to_string_pretty(cov).context("serialize coverage")?;
25    std::fs::write(path, text).with_context(|| format!("write {path:?}"))?;
26    Ok(())
27}
28
29#[cfg(test)]
30mod tests {
31    use super::*;
32    use chrono::{NaiveDate, TimeZone, Utc};
33    use shiplog_ids::EventId;
34    use shiplog_ids::RunId;
35    use shiplog_schema::coverage::{Completeness, CoverageManifest, TimeWindow};
36    use shiplog_schema::event::*;
37
38    fn pr_event(repo: &str, number: u64, title: &str) -> EventEnvelope {
39        EventEnvelope {
40            id: EventId::from_parts(["github", "pr", repo, &number.to_string()]),
41            kind: EventKind::PullRequest,
42            occurred_at: Utc.timestamp_opt(0, 0).unwrap(),
43            actor: Actor {
44                login: "user".into(),
45                id: None,
46            },
47            repo: RepoRef {
48                full_name: repo.to_string(),
49                html_url: Some(format!("https://github.com/{repo}")),
50                visibility: RepoVisibility::Unknown,
51            },
52            payload: EventPayload::PullRequest(PullRequestEvent {
53                number,
54                title: title.to_string(),
55                state: PullRequestState::Merged,
56                created_at: Utc.timestamp_opt(0, 0).unwrap(),
57                merged_at: Some(Utc.timestamp_opt(0, 0).unwrap()),
58                additions: Some(1),
59                deletions: Some(0),
60                changed_files: Some(1),
61                touched_paths_hint: vec![],
62                window: Some(TimeWindow {
63                    since: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
64                    until: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
65                }),
66            }),
67            tags: vec![],
68            links: vec![Link {
69                label: "pr".into(),
70                url: format!("https://github.com/{repo}/pull/{number}"),
71            }],
72            source: SourceRef {
73                system: SourceSystem::Github,
74                url: Some("https://api.github.com/...".into()),
75                opaque_id: None,
76            },
77        }
78    }
79
80    fn test_coverage() -> CoverageManifest {
81        CoverageManifest {
82            run_id: RunId("test_run".into()),
83            generated_at: Utc.timestamp_opt(0, 0).unwrap(),
84            user: "tester".into(),
85            window: TimeWindow {
86                since: NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
87                until: NaiveDate::from_ymd_opt(2025, 2, 1).unwrap(),
88            },
89            mode: "merged".into(),
90            sources: vec!["github".into()],
91            slices: vec![],
92            warnings: vec![],
93            completeness: Completeness::Complete,
94        }
95    }
96
97    #[test]
98    fn jsonl_round_trip() {
99        let dir = tempfile::tempdir().unwrap();
100        let path = dir.path().join("events.jsonl");
101
102        let events = vec![
103            pr_event("acme/foo", 1, "First PR"),
104            pr_event("acme/foo", 2, "Second PR"),
105        ];
106
107        write_events_jsonl(&path, &events).unwrap();
108
109        let text = std::fs::read_to_string(&path).unwrap();
110        for (i, line) in text.lines().enumerate() {
111            let ev: EventEnvelope = serde_json::from_str(line)
112                .unwrap_or_else(|e| panic!("line {i} failed to parse: {e}"));
113            assert_eq!(ev.id, events[i].id);
114        }
115    }
116
117    #[test]
118    fn coverage_manifest_round_trip() {
119        let dir = tempfile::tempdir().unwrap();
120        let path = dir.path().join("coverage.manifest.json");
121
122        let cov = test_coverage();
123        write_coverage_manifest(&path, &cov).unwrap();
124
125        let text = std::fs::read_to_string(&path).unwrap();
126        let loaded: CoverageManifest = serde_json::from_str(&text).unwrap();
127        assert_eq!(loaded.run_id, cov.run_id);
128        assert_eq!(loaded.user, cov.user);
129        assert_eq!(loaded.completeness, cov.completeness);
130    }
131
132    #[test]
133    fn empty_events_produces_empty_file() {
134        let dir = tempfile::tempdir().unwrap();
135        let path = dir.path().join("events.jsonl");
136
137        write_events_jsonl(&path, &[]).unwrap();
138
139        let text = std::fs::read_to_string(&path).unwrap();
140        assert!(text.is_empty());
141    }
142
143    #[test]
144    fn multiple_events_one_per_line() {
145        let dir = tempfile::tempdir().unwrap();
146        let path = dir.path().join("events.jsonl");
147
148        let n = 5;
149        let events: Vec<_> = (1..=n)
150            .map(|i| pr_event("acme/foo", i, &format!("PR {i}")))
151            .collect();
152
153        write_events_jsonl(&path, &events).unwrap();
154
155        let text = std::fs::read_to_string(&path).unwrap();
156        let lines: Vec<_> = text.lines().collect();
157        assert_eq!(lines.len(), n as usize);
158    }
159}