use std::collections::BTreeMap;
use std::path::PathBuf;
use muniment::StoreError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
pub(crate) const RECEIPT_NAMESPACE: Uuid =
Uuid::from_u128(0x8f2c_41d7_5e3b_4a90_9c17_6de2_0b84_f5a3);
pub const FACET_RUN: &str = "receipt.run";
pub const FACET_ARTIFACTS: &str = "receipt.artifacts";
pub const ADDRESS_PREFIX: &str = "receipt:";
#[derive(Debug)]
pub enum ReceiptError {
NoManifest(PathBuf),
Manifest(serde_json::Error),
MissingArtifact(String),
DigestMismatch {
name: String,
expected: String,
found: String,
},
Io(std::io::Error),
Store(StoreError),
}
impl std::fmt::Display for ReceiptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoManifest(path) => {
write!(f, "no manifest.json in {}", path.display())
}
Self::Manifest(error) => write!(f, "manifest.json did not parse: {error}"),
Self::MissingArtifact(name) => {
write!(f, "manifest names `{name}`, which is not in the directory")
}
Self::DigestMismatch {
name,
expected,
found,
} => write!(
f,
"`{name}` does not match its recorded digest \
(expected {expected}, found {found})"
),
Self::Io(error) => write!(f, "reading the receipt failed: {error}"),
Self::Store(error) => write!(f, "storing a blob failed: {error:?}"),
}
}
}
impl std::error::Error for ReceiptError {}
impl From<std::io::Error> for ReceiptError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<StoreError> for ReceiptError {
fn from(error: StoreError) -> Self {
Self::Store(error)
}
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct ManifestArtifact {
pub name: String,
pub bytes: u64,
pub sha256: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReceiptManifest {
pub repo: String,
pub package: String,
pub scenario: String,
pub target: String,
pub platform: String,
pub remote_os: String,
pub remote_commit: String,
#[serde(default)]
pub remote_dirty: u32,
#[serde(default)]
pub session: String,
pub ran_at_utc: String,
pub exit_code: i32,
#[serde(default)]
pub artifacts: Vec<ManifestArtifact>,
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
impl ReceiptManifest {
pub fn parse(json: &str) -> Result<Self, ReceiptError> {
serde_json::from_str(json).map_err(ReceiptError::Manifest)
}
pub fn address(&self) -> String {
format!(
"receipt:{}/{}/{}/{}",
self.repo,
self.host(),
self.scenario,
self.ran_at_utc
)
}
pub fn host(&self) -> &str {
self.target.rsplit('@').next().unwrap_or(&self.target)
}
pub fn node_id(&self) -> Uuid {
Uuid::new_v5(&RECEIPT_NAMESPACE, self.address().as_bytes())
}
pub fn title(&self) -> String {
let verdict = if self.exit_code == 0 { "ok" } else { "failed" };
format!(
"{} · {} on {} · {verdict}",
self.repo,
scenario_name(&self.scenario),
self.host()
)
}
pub fn passed(&self) -> bool {
self.exit_code == 0
}
pub fn ran_at_ms(&self) -> u64 {
parse_rfc3339_ms(&self.ran_at_utc).unwrap_or(0)
}
}
fn scenario_name(scenario: &str) -> &str {
scenario
.rsplit(['/', '\\'])
.next()
.unwrap_or(scenario)
.trim_end_matches(".scn")
}
pub(crate) fn parse_rfc3339_ms(text: &str) -> Option<u64> {
let text = text.trim();
let (date, rest) = text.split_once('T')?;
let mut date_parts = date.split('-');
let year: i64 = date_parts.next()?.parse().ok()?;
let month: i64 = date_parts.next()?.parse().ok()?;
let day: i64 = date_parts.next()?.parse().ok()?;
let time = rest
.trim_end_matches('Z')
.split_once('+')
.map(|(t, _)| t)
.unwrap_or_else(|| rest.trim_end_matches('Z'));
let mut time_parts = time.split(':');
let hour: i64 = time_parts.next()?.parse().ok()?;
let minute: i64 = time_parts.next()?.parse().ok()?;
let seconds_field = time_parts.next()?;
let (secs, frac) = seconds_field
.split_once('.')
.unwrap_or((seconds_field, "0"));
let second: i64 = secs.parse().ok()?;
let millis: i64 = format!("{frac:0<3}")[..3].parse().ok()?;
let y = if month <= 2 { year - 1 } else { year };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (month + 9) % 12;
let doy = (153 * mp + 2) / 5 + day - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
let total = ((days * 86_400 + hour * 3_600 + minute * 60 + second) * 1_000) + millis;
u64::try_from(total).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc3339_parses_to_unix_millis() {
assert_eq!(
parse_rfc3339_ms("2026-08-10T14:31:05.1234567Z"),
Some(1_786_372_265_123),
);
assert_eq!(parse_rfc3339_ms("1970-01-01T00:00:00.000Z"), Some(0));
assert_eq!(
parse_rfc3339_ms("2026-08-10T14:31:05Z"),
Some(1_786_372_265_000),
);
assert_eq!(parse_rfc3339_ms("not a time"), None);
}
}