1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub struct PreviewCritique {
20 pub severity: String,
22 pub code: String,
24 pub message: String,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36pub struct PreviewRecord {
37 pub id: String,
39 pub seq: u64,
41 pub candidate_page_id: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub source_hash: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub output: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub output_hash: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub parent_revision: Option<String>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub critiques: Vec<PreviewCritique>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub timestamp_ms: Option<u128>,
61}
62
63pub 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
77pub 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#[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 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}