use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WarningCode {
#[serde(rename = "axon-W002")]
AxonW002,
}
impl WarningCode {
pub fn slug(&self) -> &'static str {
match self {
Self::AxonW002 => "axon-W002",
}
}
pub fn message(&self) -> &'static str {
match self {
Self::AxonW002 => "streaming-not-supported",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FallbackMode {
UnknownBackend,
SourceCompilationFailed,
BackendLacksStream,
}
impl FallbackMode {
pub fn slug(&self) -> &'static str {
match self {
Self::UnknownBackend => "unknown_backend",
Self::SourceCompilationFailed => "source_compilation_failed",
Self::BackendLacksStream => "backend_lacks_stream",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeWarning {
pub code: WarningCode,
pub flow_name: String,
pub backend: String,
pub fallback_mode: FallbackMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step_name: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub declared_output: String,
pub message: String,
pub timestamp_ms: u64,
}
impl RuntimeWarning {
pub fn streaming_not_supported(
flow_name: impl Into<String>,
backend: impl Into<String>,
fallback_mode: FallbackMode,
detail: impl Into<String>,
) -> Self {
let detail = detail.into();
let message = if detail.is_empty() {
WarningCode::AxonW002.message().to_string()
} else {
format!("{}: {}", WarningCode::AxonW002.message(), detail)
};
Self {
code: WarningCode::AxonW002,
flow_name: flow_name.into(),
backend: backend.into(),
fallback_mode,
step_name: None,
declared_output: String::new(),
message,
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0),
}
}
}
#[cfg(test)]
mod closed_catalog_pins {
use super::*;
#[test]
fn warning_code_catalog_has_exactly_one_variant() {
let all = [WarningCode::AxonW002];
assert_eq!(all.len(), 1);
}
#[test]
fn warning_code_slug_is_kebab_case_with_axon_w_prefix() {
for code in [WarningCode::AxonW002] {
let slug = code.slug();
assert!(
slug.starts_with("axon-W"),
"WarningCode slug MUST start with 'axon-W'; got {slug:?}"
);
assert!(
slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
"WarningCode slug MUST be ASCII alphanumeric + dash; got {slug:?}"
);
}
}
#[test]
fn warning_code_slugs_are_unique() {
let all = [WarningCode::AxonW002];
let mut slugs: Vec<&str> = all.iter().map(|c| c.slug()).collect();
slugs.sort();
let mut unique = slugs.clone();
unique.dedup();
assert_eq!(slugs.len(), unique.len(), "WarningCode slugs must be unique");
}
#[test]
fn fallback_mode_catalog_has_three_variants_post_33_z_e() {
let all = [
FallbackMode::UnknownBackend,
FallbackMode::SourceCompilationFailed,
FallbackMode::BackendLacksStream,
];
assert_eq!(all.len(), 3);
}
#[test]
fn fallback_mode_slugs_are_snake_case_unique() {
let all = [
FallbackMode::UnknownBackend,
FallbackMode::SourceCompilationFailed,
FallbackMode::BackendLacksStream,
];
let mut slugs: Vec<&str> = all.iter().map(|m| m.slug()).collect();
for slug in &slugs {
assert!(
slug.chars()
.all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit()),
"FallbackMode slug MUST be snake_case; got {slug:?}"
);
}
slugs.sort();
let mut unique = slugs.clone();
unique.dedup();
assert_eq!(slugs.len(), unique.len(), "FallbackMode slugs must be unique");
}
#[test]
fn streaming_not_supported_constructor_sets_canonical_message() {
let w = RuntimeWarning::streaming_not_supported(
"Chat",
"anthropic",
FallbackMode::UnknownBackend,
"",
);
assert_eq!(w.code, WarningCode::AxonW002);
assert_eq!(w.flow_name, "Chat");
assert_eq!(w.backend, "anthropic");
assert_eq!(w.fallback_mode, FallbackMode::UnknownBackend);
assert_eq!(w.message, "streaming-not-supported");
assert!(w.timestamp_ms > 0);
}
#[test]
fn streaming_not_supported_constructor_includes_detail_in_message() {
let w = RuntimeWarning::streaming_not_supported(
"Chat",
"stub",
FallbackMode::SourceCompilationFailed,
"parse: missing closing brace",
);
assert_eq!(
w.message,
"streaming-not-supported: parse: missing closing brace"
);
}
#[test]
fn runtime_warning_serializes_with_kebab_code_and_snake_fallback() {
let w = RuntimeWarning::streaming_not_supported(
"F",
"stub",
FallbackMode::UnknownBackend,
"",
);
let json = serde_json::to_value(&w).unwrap();
assert_eq!(json["code"], "axon-W002");
assert_eq!(json["fallback_mode"], "unknown_backend");
assert_eq!(json["flow_name"], "F");
assert_eq!(json["backend"], "stub");
assert!(json.get("step_name").is_none());
assert!(json.get("declared_output").is_none());
}
#[test]
fn runtime_warning_round_trips_via_serde() {
let w = RuntimeWarning::streaming_not_supported(
"F",
"anthropic",
FallbackMode::BackendLacksStream,
"adopter's custom backend",
);
let json = serde_json::to_string(&w).unwrap();
let parsed: RuntimeWarning = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.code, WarningCode::AxonW002);
assert_eq!(parsed.flow_name, "F");
assert_eq!(parsed.backend, "anthropic");
assert_eq!(parsed.fallback_mode, FallbackMode::BackendLacksStream);
}
}