use serde::{Deserialize, Serialize};
use crate::core::{Operator, RunId, Timestamp};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(untagged)]
pub enum Score {
Pass(bool),
Number(f64),
Label(String),
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum AssessmentError {
#[error(
"'{0}' is not a metric name or label: 1 to 64 of a-z, 0-9, '.', '_' or '-', starting with a letter or digit"
)]
NotAToken(String),
#[error("a score must be a finite number")]
NotFinite,
#[error("a rationale is limited to {max} bytes, and this one is {len}")]
RationaleTooLong { len: usize, max: usize },
}
#[derive(Debug, Clone, PartialEq)]
pub struct Assessment {
of: RunId,
name: String,
value: Score,
by: Operator,
at: Timestamp,
rationale: Option<String>,
}
impl Assessment {
#[must_use]
pub const fn of(&self) -> RunId {
self.of
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn value(&self) -> &Score {
&self.value
}
#[must_use]
pub const fn by(&self) -> &Operator {
&self.by
}
#[must_use]
pub const fn at(&self) -> Timestamp {
self.at
}
#[must_use]
pub fn rationale(&self) -> Option<&str> {
self.rationale.as_deref()
}
pub(crate) const fn recorded(
of: RunId,
name: String,
value: Score,
by: Operator,
at: Timestamp,
rationale: Option<String>,
) -> Self {
Self {
of,
name,
value,
by,
at,
rationale,
}
}
pub(crate) fn into_parts(self) -> (RunId, String, Score, Operator, Timestamp, Option<String>) {
(
self.of,
self.name,
self.value,
self.by,
self.at,
self.rationale,
)
}
pub const MAX_RATIONALE: usize = 4096;
pub fn new(
of: RunId,
name: impl Into<String>,
value: Score,
by: Operator,
at: Timestamp,
rationale: Option<String>,
) -> Result<Self, AssessmentError> {
let name = name.into();
token(&name)?;
match &value {
Score::Label(label) => token(label)?,
Score::Number(n) if !n.is_finite() => return Err(AssessmentError::NotFinite),
Score::Pass(_) | Score::Number(_) => {}
}
if let Some(text) = &rationale
&& text.len() > Self::MAX_RATIONALE
{
return Err(AssessmentError::RationaleTooLong {
len: text.len(),
max: Self::MAX_RATIONALE,
});
}
Ok(Self {
of,
name,
value,
by,
at,
rationale: rationale.filter(|text| !text.trim().is_empty()),
})
}
}
fn token(text: &str) -> Result<(), AssessmentError> {
let mut chars = text.chars();
let lead = chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
let rest = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c));
if lead && rest && text.len() <= 64 {
Ok(())
} else {
Err(AssessmentError::NotAToken(text.to_owned()))
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Assessments {
pub history: Vec<Assessment>,
}
impl Assessments {
#[must_use]
pub fn current(&self) -> Vec<&Assessment> {
let mut standing: Vec<&Assessment> = Vec::new();
for assessment in &self.history {
if let Some(held) = standing
.iter_mut()
.find(|held| held.by == assessment.by && held.name == assessment.name)
{
*held = assessment;
} else {
standing.push(assessment);
}
}
standing
}
}
#[cfg(test)]
mod tests {
use super::{Assessment, AssessmentError, Score};
use crate::core::{Operator, RunId, Timestamp};
fn at() -> Timestamp {
Timestamp::from_unix_timestamp(1_760_000_000).unwrap()
}
fn by() -> Operator {
Operator::authenticated("alice").unwrap()
}
#[test]
fn prose_is_not_a_metric_or_a_label() {
let run = RunId::generate();
for bad in ["", "Helpful", "not helpful", "-x", &"a".repeat(65)] {
assert!(
matches!(
Assessment::new(run, bad, Score::Pass(true), by(), at(), None),
Err(AssessmentError::NotAToken(_))
),
"'{bad}' was accepted as a metric name"
);
}
assert!(
Assessment::new(
run,
"action",
Score::Label("sent as is".into()),
by(),
at(),
None
)
.is_err()
);
assert!(Assessment::new(run, "helpful.v2", Score::Pass(false), by(), at(), None).is_ok());
}
#[test]
fn a_number_must_be_finite() {
let run = RunId::generate();
assert_eq!(
Assessment::new(run, "score", Score::Number(f64::NAN), by(), at(), None),
Err(AssessmentError::NotFinite)
);
}
}