use std::fmt;
use base64::Engine as _;
use crate::model::value::{KindName, Ref, Value, ValueKind};
use crate::names::TYPED;
pub(crate) const BYTES: &str = "bytes";
#[derive(Debug, Clone, PartialEq)]
pub enum Spelled {
Plain(String),
Ref(Ref),
Bytes(Vec<u8>),
}
impl Spelled {
#[must_use]
pub fn kind(&self) -> ValueKind {
match self {
Spelled::Plain(_) => ValueKind::Str,
Spelled::Ref(_) => ValueKind::Ref,
Spelled::Bytes(_) => ValueKind::Bytes,
}
}
#[must_use]
pub fn into_value(self) -> Value {
match self {
Spelled::Plain(s) => Value::Str(escape_at(&s)),
Spelled::Ref(r) => Value::Ref(r),
Spelled::Bytes(b) => Value::Bytes(b),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[expect(
clippy::module_name_repetitions,
reason = "re-exported at the crate root, where `Error` alone would say nothing"
)]
pub enum TypedError {
NoKind {
spelled: String,
},
BadKind {
kind: String,
},
BadId {
kind: Option<KindName>,
id: String,
},
BadBytes(base64::DecodeError),
}
impl fmt::Display for TypedError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TypedError::NoKind { spelled } => write!(
f,
"`{spelled}`: a reference is `@kind:id` (`@file:images/logo.png`); write `@@` for a literal `@`"
),
TypedError::BadKind { kind } => write!(
f,
"`{kind}` isn't a kind (lower case letters, digits and `_`: `user`, `doc`, `file`, ...)"
),
TypedError::BadId {
kind: Some(kind),
id,
} => write!(
f,
"`@{kind}:{id}`: `{id}` isn't an id (not empty, no spaces or control characters)"
),
TypedError::BadId { kind: None, id } => write!(
f,
"`{id}` isn't an id (not empty, no spaces or control characters)"
),
TypedError::BadBytes(e) => write!(f, "`@bytes:`: not base64 ({e})"),
}
}
}
impl std::error::Error for TypedError {}
pub fn classify(s: &str) -> Result<Spelled, TypedError> {
let Some(rest) = s.strip_prefix(TYPED) else {
return Ok(Spelled::Plain(s.to_string()));
};
if rest.starts_with(TYPED) {
return Ok(Spelled::Plain(rest.to_string()));
}
let (kind, id) = rest.split_once(':').ok_or_else(|| TypedError::NoKind {
spelled: s.to_owned(),
})?;
if kind == BYTES {
return base64::engine::general_purpose::STANDARD
.decode(id)
.map(Spelled::Bytes)
.map_err(TypedError::BadBytes);
}
Ref::parse(kind, id).map(Spelled::Ref)
}
pub fn spelling(v: &Value) -> Option<String> {
Some(match v {
Value::Ref(r) => r.to_string(),
Value::Bytes(b) => format!(
"{TYPED}{BYTES}:{}",
base64::engine::general_purpose::STANDARD.encode(b)
),
_ => return None,
})
}
pub fn flatten(v: &Value) -> Value {
match v {
Value::List(items) => Value::List(items.iter().map(flatten).collect()),
Value::Map(m) => Value::Map(m.iter().map(|(k, x)| (k.to_string(), flatten(x))).collect()),
other => spelling(other).map_or_else(|| other.clone(), Value::Str),
}
}
#[must_use]
pub fn escape_at(s: &str) -> String {
if s.starts_with(TYPED) {
format!("{TYPED}{s}")
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn r(kind: &str, id: &str) -> Spelled {
Spelled::Ref(Ref::parse(kind, id).unwrap())
}
#[test]
fn reads_every_form() {
assert_eq!(classify("plain").unwrap(), Spelled::Plain("plain".into()));
assert_eq!(classify("@@me").unwrap(), Spelled::Plain("@me".into()));
assert_eq!(
classify("@file:images/logo.png").unwrap(),
r("file", "images/logo.png")
);
assert_eq!(
classify("@user:team:alice").unwrap(),
r("user", "team:alice")
);
assert_eq!(
classify("@bytes:AQID").unwrap(),
Spelled::Bytes(vec![1, 2, 3])
);
}
#[test]
fn rejects_typos() {
assert!(matches!(classify("@"), Err(TypedError::NoKind { .. })));
assert!(matches!(classify("@logo"), Err(TypedError::NoKind { .. })));
assert!(matches!(
classify("@File:logo"),
Err(TypedError::BadKind { .. })
));
assert!(matches!(
classify("@file:"),
Err(TypedError::BadId { kind: Some(_), .. })
));
assert!(matches!(
classify("@file:a b"),
Err(TypedError::BadId { kind: Some(_), .. })
));
assert!(matches!(
classify("@bytes:!!"),
Err(TypedError::BadBytes(_))
));
}
#[test]
fn round_trips() {
for s in [
"@file:images/logo.png",
"@doc:guides/intro[0]",
"@bytes:AQID",
] {
assert_eq!(spelling(&classify(s).unwrap().into_value()).unwrap(), s);
}
assert_eq!(
classify("@@at").unwrap().into_value(),
Value::Str("@@at".into())
);
}
}