pub mod builtins;
pub mod inventory;
pub mod query;
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Reversibility {
#[default]
OneWay,
Reversible,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum Queryability {
#[default]
Disabled,
PredicateOnly,
OrderOnly,
PredicateAndOrder,
}
#[non_exhaustive]
#[derive(Debug)]
pub enum PresentationStartupError {
MissingEnvVar {
name: &'static str,
},
NonUnicodeEnvVar {
name: &'static str,
},
InvalidHexLength {
name: &'static str,
actual: usize,
},
InvalidHexByte {
name: &'static str,
idx: usize,
},
NonLowercaseHexByte {
name: &'static str,
idx: usize,
},
Codec {
codec_path: &'static str,
source: Box<dyn std::error::Error + Send + Sync>,
},
Usage {
model: &'static str,
field: &'static str,
scope: &'static str,
codec_path: &'static str,
source: Box<PresentationStartupError>,
},
}
impl std::fmt::Display for PresentationStartupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingEnvVar { name } => {
write!(f, "presentation codec startup: missing env var `{name}`")
}
Self::NonUnicodeEnvVar { name } => {
write!(
f,
"presentation codec startup: env var `{name}` contains non-UTF-8 bytes"
)
}
Self::InvalidHexLength { name, actual } => {
write!(
f,
"presentation codec startup: env var `{name}` must be exactly 64 lowercase \
hex characters (32 bytes); got {actual} characters"
)
}
Self::InvalidHexByte { name, idx } => {
write!(
f,
"presentation codec startup: env var `{name}` contains an invalid hex \
character at index {idx} (expected `0`–`9` or `a`–`f`)"
)
}
Self::NonLowercaseHexByte { name, idx } => {
write!(
f,
"presentation codec startup: env var `{name}` contains an uppercase hex \
character at index {idx}; presentation HMAC requires lowercase-only \
hex characters (`a`–`f`, not `A`–`F`)"
)
}
Self::Codec { codec_path, source } => {
write!(
f,
"presentation codec startup: codec `{codec_path}` failed validation: {source}"
)
}
Self::Usage {
model,
field,
scope,
codec_path,
source,
} => {
let source = source.to_string();
let source = source
.strip_prefix("presentation codec startup: ")
.unwrap_or(&source);
write!(
f,
"presentation codec startup: {model}.{field} scope `{scope}` \
codec `{codec_path}` failed: {source}"
)
}
}
}
}
impl std::error::Error for PresentationStartupError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Codec { source, .. } => Some(source.as_ref()),
Self::Usage { source, .. } => Some(source.as_ref()),
_ => None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuiltInPresentationError {
KeyNotValidated {
env: &'static str,
},
}
impl std::fmt::Display for BuiltInPresentationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::KeyNotValidated { env } => {
write!(
f,
"built-in presentation codec key not validated: \
call `djogi::presentation::validate_startup_inventory()` \
before projecting visages, or set `{env}` and retry startup"
)
}
}
}
}
impl std::error::Error for BuiltInPresentationError {}
pub trait PresentationCodecInfo<Input>: Send + Sync + 'static {
type Output: Clone + std::fmt::Debug + serde::Serialize + serde::de::DeserializeOwned + 'static;
const REVERSIBILITY: Reversibility = Reversibility::OneWay;
const QUERYABILITY: Queryability = Queryability::Disabled;
fn validate_startup() -> Result<(), PresentationStartupError> {
Ok(())
}
}
pub trait PresentationCodec<Input>: PresentationCodecInfo<Input> {
fn present(value: &Input) -> <Self as PresentationCodecInfo<Input>>::Output;
}
pub trait TryPresentationCodec<Input>: PresentationCodecInfo<Input> {
type Error: std::error::Error + Send + Sync + 'static;
fn try_present(
value: &Input,
) -> Result<<Self as PresentationCodecInfo<Input>>::Output, Self::Error>;
}
pub trait ReversiblePresentationCodec<Input>: PresentationCodec<Input> {
type ReverseError: std::error::Error + Send + Sync + 'static;
fn try_reverse(
value: &<Self as PresentationCodecInfo<Input>>::Output,
) -> Result<Input, Self::ReverseError>;
}
pub trait ReversibleTryPresentationCodec<Input>: TryPresentationCodec<Input> {
type ReverseError: std::error::Error + Send + Sync + 'static;
fn try_reverse(
value: &<Self as PresentationCodecInfo<Input>>::Output,
) -> Result<Input, Self::ReverseError>;
}
pub fn validate_startup_inventory() -> Result<(), Vec<PresentationStartupError>> {
let errors: Vec<PresentationStartupError> =
::inventory::iter::<inventory::PresentationCodecUsage>
.into_iter()
.filter_map(|usage| {
(usage.validate_startup)()
.err()
.map(|e| PresentationStartupError::Usage {
model: usage.model,
field: usage.field,
scope: usage.scope,
codec_path: usage.codec_path,
source: Box::new(e),
})
})
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_startup_inventory_empty_inventory_returns_ok() {
let result = validate_startup_inventory();
assert!(
result.is_ok(),
"expected Ok(()), got Err with {} error(s)",
result.unwrap_err().len()
);
}
#[test]
fn reversibility_default_is_one_way() {
assert_eq!(Reversibility::default(), Reversibility::OneWay);
}
#[test]
fn queryability_default_is_disabled() {
assert_eq!(Queryability::default(), Queryability::Disabled);
}
#[test]
fn presentation_startup_error_display_missing_env_var() {
let e = PresentationStartupError::MissingEnvVar { name: "MY_KEY" };
let msg = e.to_string();
assert!(
msg.contains("MY_KEY"),
"display must include var name: {msg}"
);
}
#[test]
fn presentation_startup_error_display_invalid_hex_length() {
let e = PresentationStartupError::InvalidHexLength {
name: "MY_KEY",
actual: 32,
};
let msg = e.to_string();
assert!(
msg.contains("MY_KEY"),
"display must include var name: {msg}"
);
assert!(
msg.contains("32"),
"display must include actual length: {msg}"
);
assert!(
msg.contains("64"),
"display must include expected length: {msg}"
);
}
#[test]
fn presentation_startup_error_display_non_lowercase_hex() {
let e = PresentationStartupError::NonLowercaseHexByte {
name: "MY_KEY",
idx: 5,
};
let msg = e.to_string();
assert!(
msg.contains("MY_KEY"),
"display must include var name: {msg}"
);
assert!(
msg.contains('5'.to_string().as_str()),
"display must include index: {msg}"
);
}
#[test]
fn presentation_startup_error_display_usage_wraps_source() {
let inner = PresentationStartupError::MissingEnvVar { name: "KEY" };
let e = PresentationStartupError::Usage {
model: "User",
field: "email",
scope: "public",
codec_path: "crate::MyCodec",
source: Box::new(inner),
};
let msg = e.to_string();
assert!(msg.contains("User"), "display must include model: {msg}");
assert!(msg.contains("email"), "display must include field: {msg}");
assert!(msg.contains("public"), "display must include scope: {msg}");
assert_eq!(
msg.matches("presentation codec startup:").count(),
1,
"usage display must not duplicate the startup prefix: {msg}"
);
assert!(
!msg.contains("failed: presentation codec startup:"),
"usage display must not prefix the nested source twice: {msg}"
);
}
#[test]
fn builtin_presentation_error_display_key_not_validated() {
let e = BuiltInPresentationError::KeyNotValidated {
env: "DJOGI_PRESENTATION_HMAC_KEY",
};
let msg = e.to_string();
assert!(
msg.contains("DJOGI_PRESENTATION_HMAC_KEY"),
"display must include env var name: {msg}"
);
assert!(
msg.contains("validate_startup_inventory"),
"display must mention validate_startup_inventory: {msg}"
);
}
#[test]
fn builtin_presentation_error_is_std_error() {
let e = BuiltInPresentationError::KeyNotValidated {
env: "DJOGI_PRESENTATION_HMAC_KEY",
};
let dyn_err: &dyn std::error::Error = &e;
assert!(dyn_err.source().is_none());
}
}