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)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrReference {
pub repo: String,
pub number: u32,
pub sha: Option<String>,
}
impl PrReference {
pub fn parse(reference: &str) -> Option<Self> {
let (repo, rest) = reference.split_once('#')?;
if repo.is_empty() || !repo.contains('/') {
return None;
}
let (number, sha) = match rest.split_once('@') {
Some((number, sha)) => (number, Some(sha)),
None => (rest, None),
};
let number: u32 = number.parse().ok()?;
let sha = sha.filter(|sha| !sha.is_empty()).map(str::to_string);
Some(Self {
repo: repo.to_string(),
number,
sha,
})
}
pub fn matches(&self, identity: &PrIdentity) -> bool {
self.repo == identity.repo
&& self.number == identity.number
&& self
.sha
.as_ref()
.is_none_or(|sha| identity.shas.iter().any(|known| known == sha))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrIdentity {
pub repo: String,
pub number: u32,
pub shas: Vec<String>,
}
pub fn github_repo_slug(url: &str) -> Option<String> {
let path = url.split("github.com/").nth(1)?;
let mut parts = path.split('/');
let owner = parts.next().filter(|part| !part.is_empty())?;
let repo = parts.next().filter(|part| !part.is_empty())?;
Some(format!("{owner}/{repo}"))
}
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(_))
));
}
#[test]
fn pr_reference_parses_repo_number_and_optional_sha() {
let bare = PrReference::parse("loopflow/loopflow#912").expect("parse");
assert_eq!(bare.repo, "loopflow/loopflow");
assert_eq!(bare.number, 912);
assert_eq!(bare.sha, None);
let pinned = PrReference::parse("loopflow/loopflow#912@abc1234").expect("parse");
assert_eq!(pinned.repo, "loopflow/loopflow");
assert_eq!(pinned.number, 912);
assert_eq!(pinned.sha.as_deref(), Some("abc1234"));
assert_eq!(
PrReference::parse("owner/repo#0").map(|r| r.number),
Some(0)
);
assert_eq!(PrReference::parse("no-number"), None);
assert_eq!(PrReference::parse("owner/repo"), None);
assert_eq!(PrReference::parse("owner/repo#abc"), None);
assert_eq!(PrReference::parse("#912"), None);
}
#[test]
fn pr_reference_matches_repo_number_and_honors_sha() {
let identity = PrIdentity {
repo: "loopflow/loopflow".to_string(),
number: 912,
shas: vec!["mergesha".to_string(), "headsha".to_string()],
};
assert!(PrReference::parse("loopflow/loopflow#912")
.unwrap()
.matches(&identity));
assert!(PrReference::parse("loopflow/loopflow#912@mergesha")
.unwrap()
.matches(&identity));
assert!(!PrReference::parse("loopflow/loopflow#912@stale")
.unwrap()
.matches(&identity));
assert!(!PrReference::parse("other/repo#912")
.unwrap()
.matches(&identity));
assert!(!PrReference::parse("loopflow/loopflow#5")
.unwrap()
.matches(&identity));
}
#[test]
fn github_repo_slug_extracts_owner_repo() {
assert_eq!(
github_repo_slug("https://github.com/loopflow/loopflow/pull/912").as_deref(),
Some("loopflow/loopflow")
);
assert_eq!(github_repo_slug("https://example.com/x/y"), None);
}
}