1use serde::{Deserialize, Serialize};
4
5use super::registry::MimeRegistry;
6use super::{MimeType, text_plain};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum Delivery {
12 Native,
14 Text,
16 StandIn,
18}
19
20impl Delivery {
21 pub fn from_arg(word: &str) -> Result<Self, String> {
24 match word {
25 "native" => Ok(Self::Native),
26 "text" => Ok(Self::Text),
27 "stand_in" => Ok(Self::StandIn),
28 other => Err(format!(
29 "'deliver' must be native, text or stand_in, not '{other}'"
30 )),
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct BlobRef {
41 pub sha256: String,
43 pub mime_type: MimeType,
45 pub size: u64,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub width: Option<u32>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub height: Option<u32>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub duration_ms: Option<u64>,
56 #[serde(default)]
58 pub tokens: usize,
59 #[serde(default)]
63 pub stand_in: String,
64}
65
66impl BlobRef {
67 pub fn dims(&self) -> Option<(u32, u32)> {
69 Some((self.width?, self.height?))
70 }
71
72 pub fn short_sha(&self) -> &str {
74 self.sha256.get(..12).unwrap_or(&self.sha256)
75 }
76}
77
78#[derive(Clone, PartialEq, Eq)]
81pub struct Blob {
82 pub mime_type: MimeType,
84 pub bytes: Vec<u8>,
86 pub name: Option<String>,
88}
89
90impl std::fmt::Debug for Blob {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("Blob")
93 .field("mime_type", &self.mime_type)
94 .field("bytes", &format_args!("{} bytes", self.bytes.len()))
95 .field("name", &self.name)
96 .finish()
97 }
98}
99
100impl Blob {
101 pub fn new(mime_type: MimeType, bytes: Vec<u8>) -> Self {
103 Self {
104 mime_type,
105 bytes,
106 name: None,
107 }
108 }
109
110 pub fn named(mut self, name: impl Into<String>) -> Self {
112 self.name = Some(name.into());
113 self
114 }
115
116 pub fn describe(&self, reg: &MimeRegistry) -> BlobRef {
119 let info = reg.info(&self.mime_type);
120 let dims = super::probe::dimensions(&self.mime_type, &self.bytes);
121 let duration_ms = super::probe::duration_ms(&self.mime_type, &self.bytes);
122 let pages = super::probe::pages(&self.mime_type, &self.bytes);
123 let size = self.bytes.len() as u64;
124 BlobRef {
125 sha256: super::store::sha256_hex(&self.bytes),
126 mime_type: self.mime_type.clone(),
127 size,
128 width: dims.map(|d| d.0),
129 height: dims.map(|d| d.1),
130 duration_ms,
131 tokens: info.tokens.estimate(size, dims, duration_ms, pages),
132 stand_in: info.render_stand_in(self.name.as_deref(), size, dims, duration_ms, pages),
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(untagged)]
140pub enum PartBody {
141 Inline(String),
143 Stored(BlobRef),
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct Part {
155 pub mime_type: MimeType,
157 pub body: PartBody,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub name: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub deliver: Option<Delivery>,
165}
166
167impl Part {
168 pub fn text(s: impl Into<String>) -> Self {
170 Self::inline(text_plain(), s)
171 }
172
173 pub fn inline(mime_type: MimeType, s: impl Into<String>) -> Self {
175 Self {
176 mime_type,
177 body: PartBody::Inline(s.into()),
178 name: None,
179 deliver: None,
180 }
181 }
182
183 pub fn stored(blob: BlobRef) -> Self {
185 Self {
186 mime_type: blob.mime_type.clone(),
187 body: PartBody::Stored(blob),
188 name: None,
189 deliver: None,
190 }
191 }
192
193 pub fn named(mut self, name: impl Into<String>) -> Self {
195 self.name = Some(name.into());
196 self
197 }
198
199 pub fn delivered(mut self, deliver: Delivery) -> Self {
201 self.deliver = Some(deliver);
202 self
203 }
204
205 pub fn inline_text(&self) -> Option<&str> {
207 match &self.body {
208 PartBody::Inline(s) => Some(s),
209 PartBody::Stored(_) => None,
210 }
211 }
212
213 pub fn blob(&self) -> Option<&BlobRef> {
215 match &self.body {
216 PartBody::Inline(_) => None,
217 PartBody::Stored(b) => Some(b),
218 }
219 }
220
221 pub fn is_stored(&self) -> bool {
223 matches!(self.body, PartBody::Stored(_))
224 }
225
226 pub fn is_named(&self, needle: &str) -> bool {
233 let Some(blob) = self.blob() else {
234 return false;
235 };
236 if self.name.as_deref() == Some(needle) {
237 return true;
238 }
239 let basename = std::path::Path::new(needle)
240 .file_name()
241 .and_then(|n| n.to_str());
242 if basename.is_some_and(|b| b != needle && self.name.as_deref() == Some(b)) {
243 return true;
244 }
245 let lower = needle.to_ascii_lowercase();
246 lower.len() >= 6 && blob.sha256.starts_with(&lower)
247 }
248
249 pub fn tokens(&self, reg: &MimeRegistry) -> usize {
259 match &self.body {
260 PartBody::Inline(s) => {
261 let info = reg.info(&self.mime_type);
262 match info.tokens {
263 super::TokenRule::PerByte(rate) if (rate - 0.25).abs() < f64::EPSILON => {
264 crate::text::estimate_tokens(s)
265 }
266 rule => rule.estimate(s.len() as u64, None, None, None),
267 }
268 }
269 PartBody::Stored(b) => crate::text::estimate_tokens(&b.stand_in),
270 }
271 }
272
273 pub fn stand_in(&self) -> String {
276 match &self.body {
277 PartBody::Inline(s) => s.clone(),
278 PartBody::Stored(b) => b.stand_in.clone(),
279 }
280 }
281
282 pub fn handle(&self) -> Option<String> {
285 self.name
286 .clone()
287 .or_else(|| self.blob().map(|b| b.short_sha().to_string()))
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn a_delivery_word_parses_or_names_the_three_words() {
297 assert_eq!(Delivery::from_arg("native"), Ok(Delivery::Native));
298 assert_eq!(Delivery::from_arg("text"), Ok(Delivery::Text));
299 assert_eq!(Delivery::from_arg("stand_in"), Ok(Delivery::StandIn));
300 let err = Delivery::from_arg("loud").unwrap_err();
301 assert!(
302 err.contains("native, text or stand_in, not 'loud'"),
303 "{err}"
304 );
305 }
306
307 #[test]
310 fn a_part_is_named_by_its_name_its_file_name_or_its_hash() {
311 let reg = MimeRegistry::builtin();
312 let blob = Blob::new(mt("image/png"), b"\x89PNG\r\n\x1a\nbytes".to_vec()).describe(®);
313 let sha = blob.sha256.clone();
314 let part = Part::stored(blob).named("hero.png");
315 assert!(part.is_named("hero.png"));
316 assert!(part.is_named("out/hero.png"));
317 assert!(!part.is_named("hero"));
318 assert!(part.is_named(&sha.get(..6).unwrap().to_ascii_uppercase()));
319 assert!(!part.is_named(sha.get(..5).unwrap()));
320 assert!(!Part::text("hero.png").is_named("hero.png"));
321 }
322
323 fn mt(s: &str) -> MimeType {
324 MimeType::parse(s).unwrap()
325 }
326
327 #[test]
328 fn text_parts_are_inline_text_plain() {
329 let p = Part::text("hello");
330 assert_eq!(p.mime_type.as_str(), "text/plain");
331 assert_eq!(p.inline_text(), Some("hello"));
332 assert!(p.blob().is_none());
333 assert!(!p.is_stored());
334 let reg = MimeRegistry::builtin();
335 assert_eq!(p.tokens(®), 2);
336 assert_eq!(p.stand_in(), "hello");
337 assert!(p.handle().is_none());
338 let json = serde_json::to_string(&p).unwrap();
339 assert_eq!(json, r#"{"mime_type":"text/plain","body":"hello"}"#);
340 let back: Part = serde_json::from_str(&json).unwrap();
341 assert_eq!(back, p);
342 }
343
344 #[test]
345 fn inline_parts_of_other_text_types() {
346 let reg = MimeRegistry::builtin();
347 let p = Part::inline(mt("model/obj"), "v 1 2 3\n").named("cube.obj");
348 assert_eq!(p.handle().as_deref(), Some("cube.obj"));
349 assert_eq!(p.tokens(®), 2);
350 let mut reg2 = MimeRegistry::empty();
351 let t: toml::Table =
352 toml::from_str("[\"x/fixed\"]\ntext = true\ntokens = { fixed = 9 }").unwrap();
353 reg2.layer(&t, "t").unwrap();
354 let q = Part::inline(mt("x/fixed"), "anything");
355 assert_eq!(q.tokens(®2), 9);
356 }
357
358 #[test]
359 fn stored_parts_carry_their_reference() {
360 let reg = MimeRegistry::builtin();
361 let blob = Blob::new(mt("image/png"), b"\x89PNG\r\n\x1a\nxxxx".to_vec()).named("a.png");
362 assert!(format!("{blob:?}").contains("12 bytes"));
363 let r = blob.describe(®);
364 assert_eq!(r.size, 12);
365 assert_eq!(r.sha256.len(), 64);
366 assert_eq!(r.short_sha().len(), 12);
367 assert!(r.dims().is_none());
368 assert_eq!(r.tokens, 1600);
369 let p = Part::stored(r.clone())
370 .named("a.png")
371 .delivered(Delivery::Text);
372 assert!(p.is_stored());
373 assert_eq!(p.blob(), Some(&r));
374 assert!(p.inline_text().is_none());
375 assert_eq!(p.tokens(®), crate::text::estimate_tokens(&r.stand_in));
379 assert!(p.tokens(®) < r.tokens);
380 assert_eq!(p.stand_in(), "[image/png, 12 B] a.png");
381 assert_eq!(r.stand_in, "[image/png, 12 B] a.png");
382 assert_eq!(p.deliver, Some(Delivery::Text));
383 let json = serde_json::to_string(&p).unwrap();
384 assert!(json.contains("\"deliver\":\"text\""));
385 let back: Part = serde_json::from_str(&json).unwrap();
386 assert_eq!(back, p);
387 let unnamed = Part::stored(r.clone());
388 assert_eq!(unnamed.handle().unwrap(), r.short_sha());
389 let short = BlobRef {
390 sha256: "abc".into(),
391 width: Some(4),
392 ..r
393 };
394 assert_eq!(short.short_sha(), "abc");
395 assert_eq!(short.dims(), None, "a width without a height is not a size");
396 }
397
398 #[test]
399 fn dims_and_duration_flow_through() {
400 let reg = MimeRegistry::builtin();
401 let mut png = vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
402 png.extend_from_slice(&[0, 0, 0, 13, b'I', b'H', b'D', b'R']);
403 png.extend_from_slice(&[0, 0, 0x04, 0x00, 0, 0, 0x03, 0x00]);
404 let r = Blob::new(mt("image/png"), png)
405 .named("hero.png")
406 .describe(®);
407 assert_eq!(r.dims(), Some((1024, 768)));
408 assert_eq!(r.tokens, 1049);
409 let p = Part::stored(r).named("hero.png");
410 assert_eq!(p.stand_in(), "[image/png 1024x768, 24 B] hero.png");
411 let unnamed = Blob::new(mt("audio/wav"), vec![1, 2, 3]).describe(®);
412 let mut pdf =
416 b"%PDF-1.7\n<< /Type /Pages /Count 2 >> << /Type /Page >> << /Type /Page >>".to_vec();
417 pdf.extend(std::iter::repeat_n(b' ', 2 * 1024 * 1024));
418 let brochure = Blob::new(mt("application/pdf"), pdf)
419 .named("brochure.pdf")
420 .describe(®);
421 assert_eq!(brochure.tokens, 4000);
422 assert!(
423 brochure.stand_in.contains("2 pages"),
424 "{}",
425 brochure.stand_in
426 );
427 assert!(
428 brochure.stand_in.contains("brochure.pdf"),
429 "{}",
430 brochure.stand_in
431 );
432 assert_eq!(Part::stored(unnamed).stand_in(), "[audio/wav, 3 B]");
433 }
434
435 #[test]
436 fn delivery_serialises_snake_case() {
437 assert_eq!(
438 serde_json::to_string(&Delivery::StandIn).unwrap(),
439 "\"stand_in\""
440 );
441 assert_eq!(
442 serde_json::from_str::<Delivery>("\"native\"").unwrap(),
443 Delivery::Native
444 );
445 }
446}