Skip to main content

zenith_session/
previews.rs

1//! Preview-artifact records: schema and append-only JSONL log.
2//!
3//! Each [`PreviewRecord`] captures the source, output path, and critique
4//! annotations of one rendered preview artifact. Records are written by the
5//! caller after the render completes; this module performs no clock reads or
6//! hash computation — those values arrive pre-computed on the record.
7
8use serde::{Deserialize, Serialize};
9
10use crate::adapter::Fs;
11use crate::error::SessionError;
12use crate::layout::StorePaths;
13use crate::manifest::{append_jsonl_record, read_jsonl_records};
14
15// ── PreviewCritique ───────────────────────────────────────────────────────────
16
17/// A single critique annotation attached to a preview artifact.
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct PreviewCritique {
20    /// Severity level (e.g. `"error"`, `"warning"`, `"info"`).
21    pub severity: String,
22    /// Machine-readable critique code (e.g. `"contrast.too_low"`).
23    pub code: String,
24    /// Human-readable critique message.
25    pub message: String,
26}
27
28// ── PreviewRecord ─────────────────────────────────────────────────────────────
29
30/// A top-level preview-artifact record appended to `previews.jsonl`.
31///
32/// The caller is responsible for computing `timestamp_ms` (unix milliseconds),
33/// `source_hash`, `output_hash`, and `output` (the rendered file path) before
34/// calling [`append_preview`]. This module performs no clock reads.
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct PreviewRecord {
37    /// Stable preview id (unique within a document's previews log).
38    pub id: String,
39    /// Monotonic sequence number within this log (0-based).
40    pub seq: u64,
41    /// Id of the page this preview is a rendering of.
42    pub candidate_page_id: String,
43    /// Optional content hash of the source artifact consumed to produce this preview.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub source_hash: Option<String>,
46    /// Optional output file path of the rendered preview.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub output: Option<String>,
49    /// Optional content hash of the rendered output file.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub output_hash: Option<String>,
52    /// Optional id of the parent revision this preview was derived from.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub parent_revision: Option<String>,
55    /// Critique annotations attached to this preview.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub critiques: Vec<PreviewCritique>,
58    /// Unix timestamp in milliseconds at which the preview was produced.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub timestamp_ms: Option<u128>,
61}
62
63// ── I/O ───────────────────────────────────────────────────────────────────────
64
65/// Append one preview-artifact record to the document's previews log.
66///
67/// Creates the log file and its parent directory if they do not yet exist.
68pub fn append_preview(
69    fs: &impl Fs,
70    paths: &StorePaths,
71    doc_id: &str,
72    record: &PreviewRecord,
73) -> Result<(), SessionError> {
74    append_jsonl_record(fs, &paths.previews_file(doc_id), record)
75}
76
77/// Read all preview-artifact records for a document in append order.
78///
79/// Returns an empty vec when no previews log exists for the document.
80pub fn read_previews(
81    fs: &impl Fs,
82    paths: &StorePaths,
83    doc_id: &str,
84) -> Result<Vec<PreviewRecord>, SessionError> {
85    read_jsonl_records(fs, &paths.previews_file(doc_id))
86}
87
88// ── Tests ─────────────────────────────────────────────────────────────────────
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::adapter::MemFs;
94    use crate::layout::StorePaths;
95
96    fn paths() -> StorePaths {
97        StorePaths::new("/data")
98    }
99
100    fn make_fs() -> MemFs {
101        MemFs::new()
102    }
103
104    #[test]
105    fn append_then_read_previews_roundtrip() {
106        let fs = make_fs();
107        let paths = paths();
108
109        let p0 = PreviewRecord {
110            id: "prev-0".to_string(),
111            seq: 0,
112            candidate_page_id: "page-a".to_string(),
113            source_hash: Some("srchash0".to_string()),
114            output: Some("/out/prev-0.png".to_string()),
115            output_hash: Some("outhash0".to_string()),
116            parent_revision: Some("rev-1".to_string()),
117            critiques: vec![
118                PreviewCritique {
119                    severity: "warning".to_string(),
120                    code: "contrast.too_low".to_string(),
121                    message: "contrast ratio below 4.5:1".to_string(),
122                },
123                PreviewCritique {
124                    severity: "info".to_string(),
125                    code: "layout.overflow".to_string(),
126                    message: "text overflows bounding box by 2px".to_string(),
127                },
128            ],
129            timestamp_ms: Some(1_700_000_000_200),
130        };
131        let p1 = PreviewRecord {
132            id: "prev-1".to_string(),
133            seq: 1,
134            candidate_page_id: "page-b".to_string(),
135            source_hash: None,
136            output: None,
137            output_hash: None,
138            parent_revision: None,
139            critiques: Vec::new(),
140            timestamp_ms: None,
141        };
142
143        append_preview(&fs, &paths, "doc1", &p0).unwrap();
144        append_preview(&fs, &paths, "doc1", &p1).unwrap();
145
146        let records = read_previews(&fs, &paths, "doc1").unwrap();
147        assert_eq!(records.len(), 2);
148        assert_eq!(records[0], p0);
149        assert_eq!(records[1], p1);
150    }
151
152    #[test]
153    fn lean_preview_omits_optionals() {
154        let fs = make_fs();
155        let paths = paths();
156
157        let rec = PreviewRecord {
158            id: "prev-lean".to_string(),
159            seq: 0,
160            candidate_page_id: "page-c".to_string(),
161            source_hash: None,
162            output: None,
163            output_hash: None,
164            parent_revision: None,
165            critiques: Vec::new(),
166            timestamp_ms: None,
167        };
168
169        append_preview(&fs, &paths, "doc1", &rec).unwrap();
170
171        let raw = fs.read(&paths.previews_file("doc1")).unwrap();
172        let line = std::str::from_utf8(&raw).unwrap();
173
174        assert!(
175            !line.contains("source_hash"),
176            "source_hash must be absent in lean form"
177        );
178        assert!(
179            !line.contains("output"),
180            "output must be absent in lean form"
181        );
182        assert!(
183            !line.contains("output_hash"),
184            "output_hash must be absent in lean form"
185        );
186        assert!(
187            !line.contains("parent_revision"),
188            "parent_revision must be absent in lean form"
189        );
190        assert!(
191            !line.contains("critiques"),
192            "critiques must be absent in lean form"
193        );
194        assert!(
195            !line.contains("timestamp_ms"),
196            "timestamp_ms must be absent in lean form"
197        );
198        assert!(line.contains("\"id\""), "id must be present");
199        assert!(line.contains("\"seq\""), "seq must be present");
200        assert!(
201            line.contains("\"candidate_page_id\""),
202            "candidate_page_id must be present"
203        );
204    }
205
206    #[test]
207    fn old_preview_line_without_new_fields_deserializes() {
208        let fs = make_fs();
209        let paths = paths();
210
211        // Simulate a JSONL line written before optional fields existed.
212        let old_line = b"{\"id\":\"prev-old\",\"seq\":3,\"candidate_page_id\":\"page-z\"}\n";
213        let preview_path = paths.previews_file("doc1");
214        fs.create_dir_all(preview_path.parent().unwrap()).unwrap();
215        fs.write(&preview_path, old_line).unwrap();
216
217        let records = read_previews(&fs, &paths, "doc1").unwrap();
218        assert_eq!(records.len(), 1);
219        assert_eq!(records[0].id, "prev-old");
220        assert_eq!(records[0].seq, 3);
221        assert_eq!(records[0].candidate_page_id, "page-z");
222        assert_eq!(records[0].source_hash, None);
223        assert_eq!(records[0].output, None);
224        assert_eq!(records[0].output_hash, None);
225        assert_eq!(records[0].parent_revision, None);
226        assert!(records[0].critiques.is_empty());
227        assert_eq!(records[0].timestamp_ms, None);
228    }
229
230    #[test]
231    fn read_previews_absent_is_empty() {
232        let fs = make_fs();
233        let paths = paths();
234
235        let records = read_previews(&fs, &paths, "no-such-doc").unwrap();
236        assert!(records.is_empty());
237    }
238}