Skip to main content

concinnity_cook/cache/
thumbnails.rs

1//! The thumbnail set inside the build segment: the baked PNG entries and the
2//! one entry mapping asset name to key.
3//!
4//! Cook produces these and the editor reads them, which makes this the one
5//! place in the cache a consumer lives outside the writer's process. Both
6//! halves are here so the key space stays in one file: a PNG is keyed by a
7//! digest of what it depicts, so two assets that look alike share one entry,
8//! and the name map is a single entry beside them rather than being folded into
9//! those keys.
10//!
11//! A reader opens the segment's index and seeks to the entries it wants. The
12//! set changes exactly when the name map does -- a content change moves a key,
13//! a rename moves a name -- so [`Thumbnails::revision`] is a digest of that one
14//! entry and nothing else. Stamping on the file itself would be wrong: the
15//! payload cache shares this segment, so every build would bump it.
16
17use std::path::Path;
18
19use sha2::{Digest, Sha256};
20
21use super::segment::Index;
22use concinnity_core::blob::CacheEntryKind;
23
24const THUMBNAIL: CacheEntryKind = CacheEntryKind::Thumbnail;
25
26// The entry holding the whole set's name -> key map. Every other thumbnail key
27// is a 64-character hex digest, so this name cannot collide with one.
28const NAMES_KEY: &str = "names";
29
30/// A read-only view of the thumbnail entries the build segment holds: the
31/// asset-name-to-key map, and the PNG bytes each key addresses.
32///
33/// Reading is best effort throughout. An absent, unreadable, or foreign
34/// segment opens as `None` and a key that resolves to nothing reads as `None`,
35/// which costs a consumer its previews and never more than that.
36pub struct Thumbnails {
37    index: Index,
38    names: Vec<(String, String)>,
39    revision: u64,
40}
41
42impl Thumbnails {
43    /// Open the anchored build segment. `None` when nothing anchored one, when
44    /// the running binary cannot be identified, or when the segment holds no
45    /// thumbnail set (a deleted `cache/` reads this way).
46    pub fn open() -> Option<Self> {
47        Self::open_at(&super::anchored_path()?, super::identity::token()?)
48    }
49
50    fn open_at(path: &Path, token: u32) -> Option<Self> {
51        let index = Index::read(path, token);
52        let bytes = index.get(THUMBNAIL, NAMES_KEY)?;
53        Some(Self {
54            names: decode_names(&bytes)?,
55            revision: revision_of(&bytes),
56            index,
57        })
58    }
59
60    /// Asset name paired with the key of its thumbnail, in bake order.
61    pub fn names(&self) -> &[(String, String)] {
62        &self.names
63    }
64
65    /// What the set is at: the same value for two segments holding the same
66    /// thumbnails under the same names, a different one as soon as either
67    /// moves. A consumer caching decoded images reloads on a change of this
68    /// and nothing else.
69    pub fn revision(&self) -> u64 {
70        self.revision
71    }
72
73    /// The PNG bytes stored under `key`, read from the segment. `None` when
74    /// the entry is absent, or when the file was replaced under the reader
75    /// (a build publishes by rename, so a stale offset reads short or reads
76    /// bytes that fail to decode).
77    pub fn png(&self, key: &str) -> Option<Vec<u8>> {
78        self.index.get(THUMBNAIL, key)
79    }
80}
81
82/// Hold a finished bake for the next [`flush`](super::flush): every image the
83/// segment does not already have, plus the name map when it moved.
84///
85/// The map is rewritten only when it differs from what is stored, so a build
86/// whose thumbnails are all reused stores nothing and writes no file.
87pub(crate) fn hold(images: &[(String, Vec<u8>)], names: &[(String, String)]) {
88    for (key, png) in images {
89        super::store(THUMBNAIL, key, png);
90    }
91    let encoded = encode_names(names);
92    if super::load(THUMBNAIL, NAMES_KEY).as_deref() != Some(encoded.as_slice()) {
93        super::store(THUMBNAIL, NAMES_KEY, &encoded);
94    }
95}
96
97/// Whether the segment already holds the thumbnail keyed `key`.
98pub(crate) fn holds(key: &str) -> bool {
99    super::contains(THUMBNAIL, key)
100}
101
102fn encode_names(names: &[(String, String)]) -> Vec<u8> {
103    postcard::to_allocvec(names).unwrap_or_default()
104}
105
106fn decode_names(bytes: &[u8]) -> Option<Vec<(String, String)>> {
107    postcard::from_bytes(bytes).ok()
108}
109
110// The leading eight bytes of the map's digest: a rename or a content change
111// moves the map, and moving the map moves this.
112fn revision_of(names: &[u8]) -> u64 {
113    let digest: [u8; 32] = Sha256::digest(names).into();
114    u64::from_le_bytes(digest[..8].try_into().expect("eight bytes of a digest"))
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    const TOKEN: u32 = 0xB0BA;
122
123    fn names(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
124        pairs
125            .iter()
126            .map(|(n, k)| ((*n).to_string(), (*k).to_string()))
127            .collect()
128    }
129
130    // Write a set the way `hold` + a flush would, without the process-global
131    // segment those go through (disabled under `cargo test`).
132    fn write_set(path: &Path, images: &[(&str, &[u8])], pairs: &[(&str, &str)]) {
133        let encoded = encode_names(&names(pairs));
134        let mut items: Vec<(CacheEntryKind, &str, &[u8])> = images
135            .iter()
136            .map(|(key, png)| (THUMBNAIL, *key, *png))
137            .collect();
138        items.push((THUMBNAIL, NAMES_KEY, &encoded));
139        let index = Index::read(path, TOKEN);
140        assert!(super::super::segment::write(path, &index, &items, TOKEN));
141    }
142
143    #[test]
144    fn a_set_round_trips_through_a_segment() {
145        let dir = tempfile::tempdir().unwrap();
146        let path = dir.path().join("segment");
147        write_set(
148            &path,
149            &[("aa", &[1, 2, 3]), ("bb", &[4])],
150            &[("red_tex", "aa"), ("box_mesh", "bb")],
151        );
152
153        let thumbs = Thumbnails::open_at(&path, TOKEN).expect("a set");
154        assert_eq!(
155            thumbs.names(),
156            names(&[("red_tex", "aa"), ("box_mesh", "bb")])
157        );
158        assert_eq!(thumbs.png("aa"), Some(vec![1, 2, 3]));
159        assert_eq!(thumbs.png("nope"), None);
160    }
161
162    // Two assets that look alike share one entry, which is what keeps the map
163    // out of the key space.
164    #[test]
165    fn two_names_may_address_one_image() {
166        let dir = tempfile::tempdir().unwrap();
167        let path = dir.path().join("1");
168        write_set(&path, &[("aa", &[9])], &[("one", "aa"), ("two", "aa")]);
169
170        let thumbs = Thumbnails::open_at(&path, TOKEN).expect("a set");
171        assert_eq!(thumbs.names().len(), 2);
172        assert_eq!(thumbs.png("aa"), Some(vec![9]));
173    }
174
175    // The staleness stamp: a rename moves the set without moving any key, and
176    // a content change moves a key without moving any name. Both have to be
177    // visible, and an unchanged bake must not be.
178    #[test]
179    fn the_revision_follows_the_name_map() {
180        let dir = tempfile::tempdir().unwrap();
181        let one = dir.path().join("one");
182        let two = dir.path().join("two");
183        let three = dir.path().join("three");
184        let four = dir.path().join("four");
185        write_set(&one, &[("aa", &[1])], &[("red_tex", "aa")]);
186        write_set(&two, &[("aa", &[1])], &[("red_tex", "aa")]);
187        write_set(&three, &[("aa", &[1])], &[("blue_tex", "aa")]);
188        write_set(&four, &[("bb", &[1])], &[("red_tex", "bb")]);
189
190        let revision = |p: &Path| Thumbnails::open_at(p, TOKEN).expect("a set").revision();
191        assert_eq!(revision(&one), revision(&two), "an unchanged bake holds");
192        assert_ne!(revision(&one), revision(&three), "a rename shows");
193        assert_ne!(revision(&one), revision(&four), "a content change shows");
194    }
195
196    // Deleting `cache/` costs previews and nothing else, and so does a segment
197    // some other binary wrote.
198    #[test]
199    fn an_absent_or_foreign_segment_opens_to_nothing() {
200        let dir = tempfile::tempdir().unwrap();
201        let path = dir.path().join("1");
202        assert!(Thumbnails::open_at(&path, TOKEN).is_none());
203
204        write_set(&path, &[("aa", &[1])], &[("red_tex", "aa")]);
205        assert!(Thumbnails::open_at(&path, TOKEN).is_some());
206        assert!(Thumbnails::open_at(&path, TOKEN + 1).is_none());
207
208        std::fs::write(&path, b"not a segment").unwrap();
209        assert!(Thumbnails::open_at(&path, TOKEN).is_none());
210    }
211
212    // A segment holding payloads but no thumbnails is not a thumbnail set: the
213    // editor shows typed icons rather than an empty grid of broken cells.
214    #[test]
215    fn a_segment_without_a_name_map_opens_to_nothing() {
216        let dir = tempfile::tempdir().unwrap();
217        let path = dir.path().join("1");
218        let index = Index::read(&path, TOKEN);
219        let payload: &[(CacheEntryKind, &str, &[u8])] =
220            &[(CacheEntryKind::Payload, "cafe", &[1, 2, 3])];
221        assert!(super::super::segment::write(&path, &index, payload, TOKEN));
222
223        assert!(Thumbnails::open_at(&path, TOKEN).is_none());
224    }
225}