use std::collections::BTreeMap;
use core::fmt;
use serde::{de, Deserialize, Deserializer, Serialize};
use thiserror::Error;
pub const INLINE_THRESHOLD_BYTES: usize = 64 * 1024;
pub const MANIFEST_SIZE_CAP_BYTES: usize = 256 * 1024;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TargetError {
#[error("target uri length {0} outside 1..=2048")]
Length(usize),
#[error("target uri contains whitespace or control characters")]
Charset,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct CanonicalUrl(String);
impl CanonicalUrl {
pub fn new(raw: &str) -> Result<Self, TargetError> {
if raw.is_empty() || raw.len() > 2048 {
return Err(TargetError::Length(raw.len()));
}
if raw.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(TargetError::Charset);
}
Ok(Self(raw.to_owned()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for CanonicalUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for CanonicalUrl {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::new(&s).map_err(de::Error::custom)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TargetMeta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_url: Option<CanonicalUrl>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MediaRef {
pub uri: CanonicalUrl,
pub content_type: String,
pub size: u64,
pub sha256: Sha256Hex,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct Sha256Hex(String);
#[derive(Debug, Error, PartialEq, Eq)]
#[error("sha256 must be 64 lowercase hex characters")]
pub struct Sha256Error;
impl Sha256Hex {
pub fn new(raw: &str) -> Result<Self, Sha256Error> {
let ok = raw.len() == 64
&& raw
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if ok {
Ok(Self(raw.to_owned()))
} else {
Err(Sha256Error)
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for Sha256Hex {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::new(&s).map_err(de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_url_rules() {
assert!(CanonicalUrl::new("ws://analysis/report.md").is_ok());
assert!(CanonicalUrl::new("/tmp/analysis.md").is_ok());
assert_eq!(CanonicalUrl::new(""), Err(TargetError::Length(0)));
assert_eq!(CanonicalUrl::new("has space"), Err(TargetError::Charset));
assert_eq!(CanonicalUrl::new("tab\there"), Err(TargetError::Charset));
let long = "x".repeat(2049);
assert!(matches!(
CanonicalUrl::new(&long),
Err(TargetError::Length(2049))
));
}
#[test]
fn sha256_hex_validation() {
let ok = "a".repeat(64);
assert!(Sha256Hex::new(&ok).is_ok());
assert_eq!(Sha256Hex::new("short"), Err(Sha256Error));
let upper = "A".repeat(64);
assert_eq!(
Sha256Hex::new(&upper),
Err(Sha256Error),
"uppercase rejected — one canonical form"
);
let nonhex = "g".repeat(64);
assert_eq!(Sha256Hex::new(&nonhex), Err(Sha256Error));
}
#[test]
fn media_ref_serde_roundtrip() {
let m = MediaRef {
uri: CanonicalUrl::new("blob://ab/abcd").unwrap(),
content_type: "image/png".to_owned(),
size: 1024,
sha256: Sha256Hex::new(&"0".repeat(64)).unwrap(),
};
let json = serde_json::to_string(&m).unwrap();
let back: MediaRef = serde_json::from_str(&json).unwrap();
assert_eq!(back, m);
}
#[test]
fn target_meta_omits_empty_fields() {
let json = serde_json::to_string(&TargetMeta::default()).unwrap();
assert_eq!(
json, "{}",
"empty snapshot serializes empty — manifests stay small"
);
}
}