use serde::{Deserialize, Serialize};
pub mod exit_codes {
pub const OK: i32 = 0;
pub const REJECT: i32 = 2;
pub const USAGE_ERROR: i32 = 3;
pub const IO_ERROR: i32 = 4;
pub const INTERNAL: i32 = 5;
pub const SLA_BREACH: i32 = 6;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[repr(u16)]
pub enum ErrorCode {
ChainHashMismatch = 1001,
GenesisHashMismatch = 1002,
SeqGap = 1003,
DuplicateEventId = 1004,
InvalidCommitment = 1005,
TamperedReceipt = 1006,
UnknownFormatVersion = 1100,
MalformedReceipt = 1101,
MissingEventType = 1102,
UnknownProfile = 1200,
ProfileViolation = 1201,
ReceiptNotFound = 1300,
ReceiptUnreadable = 1301,
WorkingDirMissing = 1302,
InvalidObjectId = 1400,
InvalidEventType = 1401,
}
impl ErrorCode {
#[inline]
pub fn code(self) -> u16 {
self as u16
}
pub fn exit_code(self) -> i32 {
match self {
ErrorCode::ChainHashMismatch
| ErrorCode::GenesisHashMismatch
| ErrorCode::SeqGap
| ErrorCode::DuplicateEventId
| ErrorCode::InvalidCommitment
| ErrorCode::TamperedReceipt => exit_codes::REJECT,
ErrorCode::UnknownFormatVersion
| ErrorCode::MalformedReceipt
| ErrorCode::MissingEventType => exit_codes::REJECT,
ErrorCode::UnknownProfile | ErrorCode::ProfileViolation => exit_codes::REJECT,
ErrorCode::ReceiptNotFound
| ErrorCode::ReceiptUnreadable
| ErrorCode::WorkingDirMissing => exit_codes::IO_ERROR,
ErrorCode::InvalidObjectId | ErrorCode::InvalidEventType => exit_codes::USAGE_ERROR,
}
}
pub fn message(self) -> &'static str {
match self {
ErrorCode::ChainHashMismatch => "chain hash mismatch",
ErrorCode::GenesisHashMismatch => "genesis hash mismatch",
ErrorCode::SeqGap => "sequence number gap detected",
ErrorCode::DuplicateEventId => "duplicate event id",
ErrorCode::InvalidCommitment => "invalid payload commitment",
ErrorCode::TamperedReceipt => "receipt has been tampered with",
ErrorCode::UnknownFormatVersion => "unknown format version",
ErrorCode::MalformedReceipt => "malformed receipt",
ErrorCode::MissingEventType => "missing event type",
ErrorCode::UnknownProfile => "unknown conformance profile",
ErrorCode::ProfileViolation => "profile violation",
ErrorCode::ReceiptNotFound => "receipt file not found",
ErrorCode::ReceiptUnreadable => "receipt file is unreadable",
ErrorCode::WorkingDirMissing => "working directory missing or uninitialized",
ErrorCode::InvalidObjectId => "invalid object id (expected id:type[:qualifier])",
ErrorCode::InvalidEventType => "invalid event type",
}
}
pub fn hint(self) -> Option<&'static str> {
match self {
ErrorCode::ChainHashMismatch | ErrorCode::TamperedReceipt => Some(
"re-run `affi assemble` from the original working directory to rebuild the receipt",
),
ErrorCode::GenesisHashMismatch => Some(
"the first event's commitment may have been modified; rebuild from source events",
),
ErrorCode::SeqGap => Some(
"events must be emitted in contiguous sequence; \
re-emit the missing events before assembling",
),
ErrorCode::DuplicateEventId => Some(
"each event must have a unique id; \
check for accidental double-emit in your pipeline",
),
ErrorCode::InvalidCommitment => Some(
"commitments must be 64-character lowercase hex BLAKE3 digests",
),
ErrorCode::UnknownFormatVersion => {
Some("this version of affidavit supports `format_version = \"core/v1\"` only")
}
ErrorCode::MalformedReceipt => {
Some("verify the file is valid JSON and was produced by `affi assemble`")
}
ErrorCode::MissingEventType => {
Some("every event must include a non-empty `event_type` field")
}
ErrorCode::UnknownProfile => {
Some("run `affi model --export-types` to list supported profiles")
}
ErrorCode::ProfileViolation => {
Some("run `affi diagnose <receipt>` for stage-level detail")
}
ErrorCode::ReceiptNotFound => Some("check the path and that the file exists"),
ErrorCode::ReceiptUnreadable => {
Some("check file permissions; the receipt must be readable by the current user")
}
ErrorCode::WorkingDirMissing => {
Some("run `affi emit` at least once to initialise the `.affi/` directory")
}
ErrorCode::InvalidObjectId => Some(
"object ids must follow the pattern `id:type` or `id:type:qualifier` \
(e.g. `repo:git`, `suite:test:unit`)",
),
ErrorCode::InvalidEventType => Some(
"event types must be non-empty lowercase strings \
with optional hyphens (e.g. `build`, `audit-log`)",
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Span {
pub file: String,
pub line: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diag {
pub code: u16,
pub message: String,
pub hint: Option<String>,
pub span: Option<Span>,
}
impl Diag {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Diag {
code: code.code(),
message: message.into(),
hint: None,
span: None,
}
}
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn with_span(mut self, file: impl Into<String>, line: Option<u32>) -> Self {
self.span = Some(Span {
file: file.into(),
line,
});
self
}
pub fn from_error(code: ErrorCode, err: &dyn std::error::Error) -> Self {
let mut d = Diag::new(code, err.to_string());
if let Some(h) = code.hint() {
d.hint = Some(h.to_string());
}
d
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_code_discriminants_are_stable() {
assert_eq!(ErrorCode::ChainHashMismatch.code(), 1001);
assert_eq!(ErrorCode::GenesisHashMismatch.code(), 1002);
assert_eq!(ErrorCode::SeqGap.code(), 1003);
assert_eq!(ErrorCode::DuplicateEventId.code(), 1004);
assert_eq!(ErrorCode::InvalidCommitment.code(), 1005);
assert_eq!(ErrorCode::TamperedReceipt.code(), 1006);
assert_eq!(ErrorCode::UnknownFormatVersion.code(), 1100);
assert_eq!(ErrorCode::MalformedReceipt.code(), 1101);
assert_eq!(ErrorCode::MissingEventType.code(), 1102);
assert_eq!(ErrorCode::UnknownProfile.code(), 1200);
assert_eq!(ErrorCode::ProfileViolation.code(), 1201);
assert_eq!(ErrorCode::ReceiptNotFound.code(), 1300);
assert_eq!(ErrorCode::ReceiptUnreadable.code(), 1301);
assert_eq!(ErrorCode::WorkingDirMissing.code(), 1302);
assert_eq!(ErrorCode::InvalidObjectId.code(), 1400);
assert_eq!(ErrorCode::InvalidEventType.code(), 1401);
}
#[test]
fn exit_codes_are_correct() {
assert_eq!(ErrorCode::ChainHashMismatch.exit_code(), exit_codes::REJECT);
assert_eq!(ErrorCode::TamperedReceipt.exit_code(), exit_codes::REJECT);
assert_eq!(ErrorCode::UnknownFormatVersion.exit_code(), exit_codes::REJECT);
assert_eq!(ErrorCode::ProfileViolation.exit_code(), exit_codes::REJECT);
assert_eq!(ErrorCode::ReceiptNotFound.exit_code(), exit_codes::IO_ERROR);
assert_eq!(
ErrorCode::WorkingDirMissing.exit_code(),
exit_codes::IO_ERROR
);
assert_eq!(
ErrorCode::InvalidObjectId.exit_code(),
exit_codes::USAGE_ERROR
);
assert_eq!(
ErrorCode::InvalidEventType.exit_code(),
exit_codes::USAGE_ERROR
);
}
#[test]
fn diag_new_sets_code_and_message() {
let d = Diag::new(ErrorCode::SeqGap, "gap at seq 5");
assert_eq!(d.code, 1003);
assert_eq!(d.message, "gap at seq 5");
assert!(d.hint.is_none());
assert!(d.span.is_none());
}
#[test]
fn diag_with_hint_chains() {
let d = Diag::new(ErrorCode::SeqGap, "gap").with_hint("fix it");
assert_eq!(d.hint.as_deref(), Some("fix it"));
}
#[test]
fn diag_with_span_chains() {
let d = Diag::new(ErrorCode::MalformedReceipt, "bad json")
.with_span("receipt.json", Some(7));
let span = d.span.as_ref().expect("span should be set");
assert_eq!(span.file, "receipt.json");
assert_eq!(span.line, Some(7));
}
#[test]
fn diag_from_error_attaches_generic_hint() {
use std::io;
let err = io::Error::new(io::ErrorKind::NotFound, "no such file");
let d = Diag::from_error(ErrorCode::ReceiptNotFound, &err);
assert_eq!(d.code, 1300);
assert!(d.hint.is_some(), "generic hint should be attached");
}
#[test]
fn diag_is_serializable() {
let d = Diag::new(ErrorCode::InvalidObjectId, "bad id 'foo'")
.with_hint("use id:type format")
.with_span("receipt.json", Some(3));
let json = serde_json::to_string(&d).expect("serialize");
assert!(json.contains("1400"));
assert!(json.contains("bad id"));
}
#[test]
fn exit_code_catalog_values() {
assert_eq!(exit_codes::OK, 0);
assert_eq!(exit_codes::REJECT, 2);
assert_eq!(exit_codes::USAGE_ERROR, 3);
assert_eq!(exit_codes::IO_ERROR, 4);
assert_eq!(exit_codes::INTERNAL, 5);
assert_eq!(exit_codes::SLA_BREACH, 6);
}
#[test]
fn all_error_codes_have_message() {
let codes = [
ErrorCode::ChainHashMismatch,
ErrorCode::GenesisHashMismatch,
ErrorCode::SeqGap,
ErrorCode::DuplicateEventId,
ErrorCode::InvalidCommitment,
ErrorCode::TamperedReceipt,
ErrorCode::UnknownFormatVersion,
ErrorCode::MalformedReceipt,
ErrorCode::MissingEventType,
ErrorCode::UnknownProfile,
ErrorCode::ProfileViolation,
ErrorCode::ReceiptNotFound,
ErrorCode::ReceiptUnreadable,
ErrorCode::WorkingDirMissing,
ErrorCode::InvalidObjectId,
ErrorCode::InvalidEventType,
];
for code in codes {
let msg = code.message();
assert!(!msg.is_empty(), "E{} has empty message", code.code());
}
}
}