use serde::{Deserialize, Serialize};
use crate::adapter::Fs;
use crate::error::SessionError;
use crate::layout::StorePaths;
use crate::manifest::{append_jsonl_record, read_jsonl_records};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PreviewCritique {
pub severity: String,
pub code: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PreviewRecord {
pub id: String,
pub seq: u64,
pub candidate_page_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_revision: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub critiques: Vec<PreviewCritique>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp_ms: Option<u128>,
}
pub fn append_preview(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
record: &PreviewRecord,
) -> Result<(), SessionError> {
append_jsonl_record(fs, &paths.previews_file(doc_id), record)
}
pub fn read_previews(
fs: &impl Fs,
paths: &StorePaths,
doc_id: &str,
) -> Result<Vec<PreviewRecord>, SessionError> {
read_jsonl_records(fs, &paths.previews_file(doc_id))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::MemFs;
use crate::layout::StorePaths;
fn paths() -> StorePaths {
StorePaths::new("/data")
}
fn make_fs() -> MemFs {
MemFs::new()
}
#[test]
fn append_then_read_previews_roundtrip() {
let fs = make_fs();
let paths = paths();
let p0 = PreviewRecord {
id: "prev-0".to_string(),
seq: 0,
candidate_page_id: "page-a".to_string(),
source_hash: Some("srchash0".to_string()),
output: Some("/out/prev-0.png".to_string()),
output_hash: Some("outhash0".to_string()),
parent_revision: Some("rev-1".to_string()),
critiques: vec![
PreviewCritique {
severity: "warning".to_string(),
code: "contrast.too_low".to_string(),
message: "contrast ratio below 4.5:1".to_string(),
},
PreviewCritique {
severity: "info".to_string(),
code: "layout.overflow".to_string(),
message: "text overflows bounding box by 2px".to_string(),
},
],
timestamp_ms: Some(1_700_000_000_200),
};
let p1 = PreviewRecord {
id: "prev-1".to_string(),
seq: 1,
candidate_page_id: "page-b".to_string(),
source_hash: None,
output: None,
output_hash: None,
parent_revision: None,
critiques: Vec::new(),
timestamp_ms: None,
};
append_preview(&fs, &paths, "doc1", &p0).unwrap();
append_preview(&fs, &paths, "doc1", &p1).unwrap();
let records = read_previews(&fs, &paths, "doc1").unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0], p0);
assert_eq!(records[1], p1);
}
#[test]
fn lean_preview_omits_optionals() {
let fs = make_fs();
let paths = paths();
let rec = PreviewRecord {
id: "prev-lean".to_string(),
seq: 0,
candidate_page_id: "page-c".to_string(),
source_hash: None,
output: None,
output_hash: None,
parent_revision: None,
critiques: Vec::new(),
timestamp_ms: None,
};
append_preview(&fs, &paths, "doc1", &rec).unwrap();
let raw = fs.read(&paths.previews_file("doc1")).unwrap();
let line = std::str::from_utf8(&raw).unwrap();
assert!(
!line.contains("source_hash"),
"source_hash must be absent in lean form"
);
assert!(
!line.contains("output"),
"output must be absent in lean form"
);
assert!(
!line.contains("output_hash"),
"output_hash must be absent in lean form"
);
assert!(
!line.contains("parent_revision"),
"parent_revision must be absent in lean form"
);
assert!(
!line.contains("critiques"),
"critiques must be absent in lean form"
);
assert!(
!line.contains("timestamp_ms"),
"timestamp_ms must be absent in lean form"
);
assert!(line.contains("\"id\""), "id must be present");
assert!(line.contains("\"seq\""), "seq must be present");
assert!(
line.contains("\"candidate_page_id\""),
"candidate_page_id must be present"
);
}
#[test]
fn old_preview_line_without_new_fields_deserializes() {
let fs = make_fs();
let paths = paths();
let old_line = b"{\"id\":\"prev-old\",\"seq\":3,\"candidate_page_id\":\"page-z\"}\n";
let preview_path = paths.previews_file("doc1");
fs.create_dir_all(preview_path.parent().unwrap()).unwrap();
fs.write(&preview_path, old_line).unwrap();
let records = read_previews(&fs, &paths, "doc1").unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].id, "prev-old");
assert_eq!(records[0].seq, 3);
assert_eq!(records[0].candidate_page_id, "page-z");
assert_eq!(records[0].source_hash, None);
assert_eq!(records[0].output, None);
assert_eq!(records[0].output_hash, None);
assert_eq!(records[0].parent_revision, None);
assert!(records[0].critiques.is_empty());
assert_eq!(records[0].timestamp_ms, None);
}
#[test]
fn read_previews_absent_is_empty() {
let fs = make_fs();
let paths = paths();
let records = read_previews(&fs, &paths, "no-such-doc").unwrap();
assert!(records.is_empty());
}
}