use serde::{Deserialize, Serialize};
use super::registry::MimeRegistry;
use super::{MimeType, text_plain};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Delivery {
Native,
Text,
StandIn,
}
impl Delivery {
pub fn from_arg(word: &str) -> Result<Self, String> {
match word {
"native" => Ok(Self::Native),
"text" => Ok(Self::Text),
"stand_in" => Ok(Self::StandIn),
other => Err(format!(
"'deliver' must be native, text or stand_in, not '{other}'"
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlobRef {
pub sha256: String,
pub mime_type: MimeType,
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default)]
pub tokens: usize,
#[serde(default)]
pub stand_in: String,
}
impl BlobRef {
pub fn dims(&self) -> Option<(u32, u32)> {
Some((self.width?, self.height?))
}
pub fn short_sha(&self) -> &str {
self.sha256.get(..12).unwrap_or(&self.sha256)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Blob {
pub mime_type: MimeType,
pub bytes: Vec<u8>,
pub name: Option<String>,
}
impl std::fmt::Debug for Blob {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Blob")
.field("mime_type", &self.mime_type)
.field("bytes", &format_args!("{} bytes", self.bytes.len()))
.field("name", &self.name)
.finish()
}
}
impl Blob {
pub fn new(mime_type: MimeType, bytes: Vec<u8>) -> Self {
Self {
mime_type,
bytes,
name: None,
}
}
pub fn named(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn describe(&self, reg: &MimeRegistry) -> BlobRef {
let info = reg.info(&self.mime_type);
let dims = super::probe::dimensions(&self.mime_type, &self.bytes);
let duration_ms = super::probe::duration_ms(&self.mime_type, &self.bytes);
let pages = super::probe::pages(&self.mime_type, &self.bytes);
let size = self.bytes.len() as u64;
BlobRef {
sha256: super::store::sha256_hex(&self.bytes),
mime_type: self.mime_type.clone(),
size,
width: dims.map(|d| d.0),
height: dims.map(|d| d.1),
duration_ms,
tokens: info.tokens.estimate(size, dims, duration_ms, pages),
stand_in: info.render_stand_in(self.name.as_deref(), size, dims, duration_ms, pages),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PartBody {
Inline(String),
Stored(BlobRef),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Part {
pub mime_type: MimeType,
pub body: PartBody,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deliver: Option<Delivery>,
}
impl Part {
pub fn text(s: impl Into<String>) -> Self {
Self::inline(text_plain(), s)
}
pub fn inline(mime_type: MimeType, s: impl Into<String>) -> Self {
Self {
mime_type,
body: PartBody::Inline(s.into()),
name: None,
deliver: None,
}
}
pub fn stored(blob: BlobRef) -> Self {
Self {
mime_type: blob.mime_type.clone(),
body: PartBody::Stored(blob),
name: None,
deliver: None,
}
}
pub fn named(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn delivered(mut self, deliver: Delivery) -> Self {
self.deliver = Some(deliver);
self
}
pub fn inline_text(&self) -> Option<&str> {
match &self.body {
PartBody::Inline(s) => Some(s),
PartBody::Stored(_) => None,
}
}
pub fn blob(&self) -> Option<&BlobRef> {
match &self.body {
PartBody::Inline(_) => None,
PartBody::Stored(b) => Some(b),
}
}
pub fn is_stored(&self) -> bool {
matches!(self.body, PartBody::Stored(_))
}
pub fn is_named(&self, needle: &str) -> bool {
let Some(blob) = self.blob() else {
return false;
};
if self.name.as_deref() == Some(needle) {
return true;
}
let basename = std::path::Path::new(needle)
.file_name()
.and_then(|n| n.to_str());
if basename.is_some_and(|b| b != needle && self.name.as_deref() == Some(b)) {
return true;
}
let lower = needle.to_ascii_lowercase();
lower.len() >= 6 && blob.sha256.starts_with(&lower)
}
pub fn tokens(&self, reg: &MimeRegistry) -> usize {
match &self.body {
PartBody::Inline(s) => {
let info = reg.info(&self.mime_type);
match info.tokens {
super::TokenRule::PerByte(rate) if (rate - 0.25).abs() < f64::EPSILON => {
crate::text::estimate_tokens(s)
}
rule => rule.estimate(s.len() as u64, None, None, None),
}
}
PartBody::Stored(b) => crate::text::estimate_tokens(&b.stand_in),
}
}
pub fn stand_in(&self) -> String {
match &self.body {
PartBody::Inline(s) => s.clone(),
PartBody::Stored(b) => b.stand_in.clone(),
}
}
pub fn handle(&self) -> Option<String> {
self.name
.clone()
.or_else(|| self.blob().map(|b| b.short_sha().to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_delivery_word_parses_or_names_the_three_words() {
assert_eq!(Delivery::from_arg("native"), Ok(Delivery::Native));
assert_eq!(Delivery::from_arg("text"), Ok(Delivery::Text));
assert_eq!(Delivery::from_arg("stand_in"), Ok(Delivery::StandIn));
let err = Delivery::from_arg("loud").unwrap_err();
assert!(
err.contains("native, text or stand_in, not 'loud'"),
"{err}"
);
}
#[test]
fn a_part_is_named_by_its_name_its_file_name_or_its_hash() {
let reg = MimeRegistry::builtin();
let blob = Blob::new(mt("image/png"), b"\x89PNG\r\n\x1a\nbytes".to_vec()).describe(®);
let sha = blob.sha256.clone();
let part = Part::stored(blob).named("hero.png");
assert!(part.is_named("hero.png"));
assert!(part.is_named("out/hero.png"));
assert!(!part.is_named("hero"));
assert!(part.is_named(&sha.get(..6).unwrap().to_ascii_uppercase()));
assert!(!part.is_named(sha.get(..5).unwrap()));
assert!(!Part::text("hero.png").is_named("hero.png"));
}
fn mt(s: &str) -> MimeType {
MimeType::parse(s).unwrap()
}
#[test]
fn text_parts_are_inline_text_plain() {
let p = Part::text("hello");
assert_eq!(p.mime_type.as_str(), "text/plain");
assert_eq!(p.inline_text(), Some("hello"));
assert!(p.blob().is_none());
assert!(!p.is_stored());
let reg = MimeRegistry::builtin();
assert_eq!(p.tokens(®), 2);
assert_eq!(p.stand_in(), "hello");
assert!(p.handle().is_none());
let json = serde_json::to_string(&p).unwrap();
assert_eq!(json, r#"{"mime_type":"text/plain","body":"hello"}"#);
let back: Part = serde_json::from_str(&json).unwrap();
assert_eq!(back, p);
}
#[test]
fn inline_parts_of_other_text_types() {
let reg = MimeRegistry::builtin();
let p = Part::inline(mt("model/obj"), "v 1 2 3\n").named("cube.obj");
assert_eq!(p.handle().as_deref(), Some("cube.obj"));
assert_eq!(p.tokens(®), 2);
let mut reg2 = MimeRegistry::empty();
let t: toml::Table =
toml::from_str("[\"x/fixed\"]\ntext = true\ntokens = { fixed = 9 }").unwrap();
reg2.layer(&t, "t").unwrap();
let q = Part::inline(mt("x/fixed"), "anything");
assert_eq!(q.tokens(®2), 9);
}
#[test]
fn stored_parts_carry_their_reference() {
let reg = MimeRegistry::builtin();
let blob = Blob::new(mt("image/png"), b"\x89PNG\r\n\x1a\nxxxx".to_vec()).named("a.png");
assert!(format!("{blob:?}").contains("12 bytes"));
let r = blob.describe(®);
assert_eq!(r.size, 12);
assert_eq!(r.sha256.len(), 64);
assert_eq!(r.short_sha().len(), 12);
assert!(r.dims().is_none());
assert_eq!(r.tokens, 1600);
let p = Part::stored(r.clone())
.named("a.png")
.delivered(Delivery::Text);
assert!(p.is_stored());
assert_eq!(p.blob(), Some(&r));
assert!(p.inline_text().is_none());
assert_eq!(p.tokens(®), crate::text::estimate_tokens(&r.stand_in));
assert!(p.tokens(®) < r.tokens);
assert_eq!(p.stand_in(), "[image/png, 12 B] a.png");
assert_eq!(r.stand_in, "[image/png, 12 B] a.png");
assert_eq!(p.deliver, Some(Delivery::Text));
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"deliver\":\"text\""));
let back: Part = serde_json::from_str(&json).unwrap();
assert_eq!(back, p);
let unnamed = Part::stored(r.clone());
assert_eq!(unnamed.handle().unwrap(), r.short_sha());
let short = BlobRef {
sha256: "abc".into(),
width: Some(4),
..r
};
assert_eq!(short.short_sha(), "abc");
assert_eq!(short.dims(), None, "a width without a height is not a size");
}
#[test]
fn dims_and_duration_flow_through() {
let reg = MimeRegistry::builtin();
let mut png = vec![0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n'];
png.extend_from_slice(&[0, 0, 0, 13, b'I', b'H', b'D', b'R']);
png.extend_from_slice(&[0, 0, 0x04, 0x00, 0, 0, 0x03, 0x00]);
let r = Blob::new(mt("image/png"), png)
.named("hero.png")
.describe(®);
assert_eq!(r.dims(), Some((1024, 768)));
assert_eq!(r.tokens, 1049);
let p = Part::stored(r).named("hero.png");
assert_eq!(p.stand_in(), "[image/png 1024x768, 24 B] hero.png");
let unnamed = Blob::new(mt("audio/wav"), vec![1, 2, 3]).describe(®);
let mut pdf =
b"%PDF-1.7\n<< /Type /Pages /Count 2 >> << /Type /Page >> << /Type /Page >>".to_vec();
pdf.extend(std::iter::repeat_n(b' ', 2 * 1024 * 1024));
let brochure = Blob::new(mt("application/pdf"), pdf)
.named("brochure.pdf")
.describe(®);
assert_eq!(brochure.tokens, 4000);
assert!(
brochure.stand_in.contains("2 pages"),
"{}",
brochure.stand_in
);
assert!(
brochure.stand_in.contains("brochure.pdf"),
"{}",
brochure.stand_in
);
assert_eq!(Part::stored(unnamed).stand_in(), "[audio/wav, 3 B]");
}
#[test]
fn delivery_serialises_snake_case() {
assert_eq!(
serde_json::to_string(&Delivery::StandIn).unwrap(),
"\"stand_in\""
);
assert_eq!(
serde_json::from_str::<Delivery>("\"native\"").unwrap(),
Delivery::Native
);
}
}