use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceKind {
ChatTurn,
WorkerReport,
Trace,
Pm,
Pr,
}
impl EvidenceKind {
pub fn as_token(self) -> &'static str {
match self {
Self::ChatTurn => "chat_turn",
Self::WorkerReport => "worker_report",
Self::Trace => "trace",
Self::Pm => "pm",
Self::Pr => "pr",
}
}
}
impl fmt::Display for EvidenceKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_token())
}
}
impl FromStr for EvidenceKind {
type Err = ReceiptParseError;
fn from_str(token: &str) -> Result<Self, Self::Err> {
match token {
"chat_turn" => Ok(Self::ChatTurn),
"worker_report" | "run" => Ok(Self::WorkerReport),
"trace" => Ok(Self::Trace),
"pm" => Ok(Self::Pm),
"pr" => Ok(Self::Pr),
other => Err(ReceiptParseError::UnknownKind(other.to_string())),
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Receipt {
pub kind: EvidenceKind,
pub reference: String,
pub wave: String,
}
impl Receipt {
pub fn new(kind: EvidenceKind, reference: impl Into<String>, wave: impl Into<String>) -> Self {
Self {
kind,
reference: reference.into(),
wave: wave.into(),
}
}
pub fn parse(token: &str, wave: &str) -> Result<Self, ReceiptParseError> {
let (kind, reference) = token
.split_once(':')
.ok_or_else(|| ReceiptParseError::Malformed(token.to_string()))?;
let reference = reference.trim();
if reference.is_empty() {
return Err(ReceiptParseError::EmptyReference(kind.to_string()));
}
Ok(Self::new(kind.parse::<EvidenceKind>()?, reference, wave))
}
pub fn token(&self) -> String {
format!("{}:{}", self.kind.as_token(), self.reference)
}
}
impl fmt::Display for Receipt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} (wave {})", self.token(), self.wave)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ReceiptParseError {
#[error("receipt '{0}' must be written as 'kind:reference' (e.g. chat_turn:turn-3)")]
Malformed(String),
#[error(
"unknown receipt kind '{0}'; expected one of chat_turn, worker_report (run), trace, pm, pr"
)]
UnknownKind(String),
#[error("receipt kind '{0}' has an empty reference")]
EmptyReference(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_each_kind_with_the_claim_wave() {
let cases = [
("chat_turn:turn-3", EvidenceKind::ChatTurn, "turn-3"),
("worker_report:run-9", EvidenceKind::WorkerReport, "run-9"),
("run:run-9", EvidenceKind::WorkerReport, "run-9"),
("trace:0c2f-uuid", EvidenceKind::Trace, "0c2f-uuid"),
("pm:issue-uuid", EvidenceKind::Pm, "issue-uuid"),
];
for (token, kind, reference) in cases {
let receipt = Receipt::parse(token, "product").expect("parse");
assert_eq!(receipt.kind, kind);
assert_eq!(receipt.reference, reference);
assert_eq!(receipt.wave, "product");
}
}
#[test]
fn pr_reference_keeps_its_own_colons_and_sha() {
let receipt = Receipt::parse("pr:loopflow/loopflow#912@abc123", "product").expect("parse");
assert_eq!(receipt.kind, EvidenceKind::Pr);
assert_eq!(receipt.reference, "loopflow/loopflow#912@abc123");
}
#[test]
fn token_round_trips_through_parse() {
let receipt = Receipt::new(EvidenceKind::ChatTurn, "turn-7", "product");
let reparsed = Receipt::parse(&receipt.token(), "product").expect("parse");
assert_eq!(receipt, reparsed);
}
#[test]
fn rejects_missing_colon_unknown_kind_and_empty_reference() {
assert!(matches!(
Receipt::parse("turn-3", "product"),
Err(ReceiptParseError::Malformed(_))
));
assert!(matches!(
Receipt::parse("bogus:x", "product"),
Err(ReceiptParseError::UnknownKind(_))
));
assert!(matches!(
Receipt::parse("chat_turn: ", "product"),
Err(ReceiptParseError::EmptyReference(_))
));
}
}