use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use tracing::{debug, info};
use super::{
Action, Condition, ConditionUpdates, EventTemplate, EventType, Guard, Role, SessionId,
StateKey, StateTable, StateTableBuilder, Transition,
};
use crate::errors::{Result, SessionError};
use crate::types::{CallState, FailureReason};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YamlStateTable {
pub version: String,
#[serde(default)]
pub metadata: YamlMetadata,
#[serde(default)]
pub states: Vec<YamlStateDefinition>,
#[serde(default)]
pub conditions: Vec<YamlConditionDefinition>,
pub transitions: Vec<YamlTransition>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct YamlMetadata {
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
#[serde(default)]
pub date: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YamlStateDefinition {
pub name: String,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YamlConditionDefinition {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub default: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct YamlTransition {
pub role: String,
pub state: String,
pub event: YamlEvent,
#[serde(default)]
pub guards: Vec<YamlGuard>,
#[serde(default)]
pub actions: Vec<YamlAction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_state: Option<String>,
#[serde(default, skip_serializing_if = "YamlConditionUpdates::is_empty")]
pub conditions: YamlConditionUpdates,
#[serde(default)]
pub publish: Vec<String>,
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum YamlEvent {
Simple(String),
Complex {
#[serde(rename = "type")]
event_type: String,
#[serde(flatten)]
parameters: HashMap<String, serde_yaml::Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum YamlGuard {
Simple(String),
Complex {
#[serde(rename = "type")]
guard_type: String,
#[serde(flatten)]
parameters: HashMap<String, serde_yaml::Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum YamlAction {
Simple(String),
Complex {
#[serde(rename = "type")]
action_type: String,
#[serde(flatten)]
parameters: HashMap<String, serde_yaml::Value>,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct YamlConditionUpdates {
#[serde(skip_serializing_if = "Option::is_none")]
pub dialog_established: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_session_ready: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sdp_negotiated: Option<bool>,
}
impl YamlConditionUpdates {
fn is_empty(&self) -> bool {
self.dialog_established.is_none()
&& self.media_session_ready.is_none()
&& self.sdp_negotiated.is_none()
}
}
const DEFAULT_STATE_TABLE_YAML: &str = include_str!("../../state_tables/default.yaml");
pub struct YamlTableLoader {
builder: StateTableBuilder,
yaml_data: Option<YamlStateTable>,
}
impl YamlTableLoader {
pub fn new() -> Self {
Self {
builder: StateTableBuilder::new(),
yaml_data: None,
}
}
pub fn load_default() -> Result<StateTable> {
Self::load_embedded_default()
}
pub fn load_embedded_default() -> Result<StateTable> {
let mut loader = Self::new();
loader
.load_from_string(DEFAULT_STATE_TABLE_YAML)
.expect("Embedded default state table must be valid");
loader.build()
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<StateTable> {
let mut loader = Self::new();
let yaml_content = fs::read_to_string(path.as_ref())
.map_err(|e| SessionError::InternalError(format!("Failed to read YAML file: {}", e)))?;
loader.load_from_string(&yaml_content)?;
loader.build()
}
pub fn load_from_string(&mut self, yaml_content: &str) -> Result<()> {
Self::validate_raw_yaml_content(yaml_content)?;
let yaml_data: YamlStateTable = serde_yaml::from_str(yaml_content)
.map_err(|e| SessionError::InternalError(format!("Failed to parse YAML: {}", e)))?;
if !yaml_data.version.starts_with("1.") && !yaml_data.version.starts_with("2.") {
return Err(SessionError::InternalError(format!(
"Unsupported state table version: {} (expected 1.x or 2.x)",
yaml_data.version
)));
}
info!(
"Loaded state table version {} with {} transitions",
yaml_data.version,
yaml_data.transitions.len()
);
self.yaml_data = Some(yaml_data);
Ok(())
}
pub fn merge_file<P: AsRef<Path>>(&mut self, path: P) -> Result<&mut Self> {
let yaml_content = fs::read_to_string(path.as_ref()).map_err(|e| {
SessionError::InternalError(format!("Failed to read YAML file for merge: {}", e))
})?;
self.merge_string(&yaml_content)?;
Ok(self)
}
pub fn merge_string(&mut self, yaml_content: &str) -> Result<()> {
Self::validate_raw_yaml_content(yaml_content)?;
let merge_data: YamlStateTable = serde_yaml::from_str(yaml_content).map_err(|e| {
SessionError::InternalError(format!("Failed to parse YAML for merge: {}", e))
})?;
if let Some(ref mut yaml_data) = self.yaml_data {
let num_transitions = merge_data.transitions.len();
yaml_data.transitions.extend(merge_data.transitions);
for state in merge_data.states {
if !yaml_data.states.iter().any(|s| s.name == state.name) {
yaml_data.states.push(state);
}
}
for condition in merge_data.conditions {
if !yaml_data
.conditions
.iter()
.any(|c| c.name == condition.name)
{
yaml_data.conditions.push(condition);
}
}
info!("Merged {} transitions into state table", num_transitions);
} else {
self.yaml_data = Some(merge_data);
}
Ok(())
}
pub fn build(mut self) -> Result<StateTable> {
let yaml_data = self
.yaml_data
.take()
.ok_or_else(|| SessionError::InternalError("No YAML data loaded".to_string()))?;
self.validate_yaml_data(&yaml_data)?;
for yaml_transition in yaml_data.transitions {
match self.convert_transition(yaml_transition) {
Ok((key, transition)) => {
self.builder.add_raw_transition(key, transition);
}
Err(SessionError::InternalError(msg))
if msg.starts_with("WILDCARD_TRANSITION:") =>
{
let parts: Vec<&str> = msg
.strip_prefix("WILDCARD_TRANSITION:")
.unwrap()
.split(':')
.collect();
if parts.len() == 3 {
if let (Ok(role), Ok(event), Ok(transition)) = (
serde_json::from_str::<Role>(parts[0]),
serde_json::from_str::<EventType>(parts[1]),
serde_json::from_str::<Transition>(parts[2]),
) {
self.builder
.add_wildcard_transition(role, event, transition);
} else {
tracing::warn!("Failed to parse wildcard transition data");
}
}
}
Err(e) => return Err(e),
}
}
Ok(self.builder.build())
}
fn validation_error(errors: Vec<String>) -> SessionError {
SessionError::InternalError(format!(
"State table YAML validation failed:\n- {}",
errors.join("\n- ")
))
}
fn validate_raw_yaml_content(yaml_content: &str) -> Result<()> {
let raw: serde_yaml::Value = serde_yaml::from_str(yaml_content)
.map_err(|e| SessionError::InternalError(format!("Failed to parse YAML: {}", e)))?;
let mut errors = Vec::new();
let allowed_condition_updates: HashSet<&str> = [
"dialog_established",
"media_session_ready",
"sdp_negotiated",
]
.into_iter()
.collect();
let transitions = raw.get("transitions").and_then(|value| value.as_sequence());
if let Some(transitions) = transitions {
for (index, transition) in transitions.iter().enumerate() {
let Some(mapping) = transition.as_mapping() else {
errors.push(format!("transition #{} is not a mapping", index + 1));
continue;
};
let Some(conditions) = mapping
.get(&serde_yaml::Value::String("conditions".to_string()))
.and_then(|value| value.as_mapping())
else {
continue;
};
for key in conditions.keys() {
let Some(key) = key.as_str() else {
errors.push(format!(
"transition #{} has a non-string condition update key",
index + 1
));
continue;
};
if !allowed_condition_updates.contains(key) {
errors.push(format!(
"transition #{} uses unsupported condition update '{}'",
index + 1,
key
));
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(Self::validation_error(errors))
}
}
fn validate_yaml_data(&self, yaml_data: &YamlStateTable) -> Result<()> {
let mut errors = Vec::new();
let mut seen_transitions: HashMap<(Role, String, String), usize> = HashMap::new();
let declared_states: HashSet<String> = yaml_data
.states
.iter()
.map(|state| state.name.clone())
.collect();
let should_validate_declared_states = !declared_states.is_empty();
for (index, transition) in yaml_data.transitions.iter().enumerate() {
let line_hint = format!("transition #{}", index + 1);
let role = match transition.role.to_lowercase().as_str() {
"uac" => Role::UAC,
"uas" | "server" => Role::UAS,
"both" => Role::Both,
_ => {
errors.push(format!(
"{} has invalid role '{}'",
line_hint, transition.role
));
continue;
}
};
if should_validate_declared_states {
for (field, state) in [
("state", Some(transition.state.as_str())),
("next_state", transition.next_state.as_deref()),
] {
let Some(state) = state else { continue };
if state == "Any" || state == "*" {
continue;
}
if !declared_states.contains(state) {
errors.push(format!(
"{} references undeclared {} '{}'",
line_hint, field, state
));
}
}
}
let event = match self.parse_event(transition.event.clone()) {
Ok(event) => event.normalize(),
Err(err) => {
errors.push(format!("{} has invalid event: {}", line_hint, err));
continue;
}
};
let event_key = format!("{:?}", event);
let key = (role, transition.state.clone(), event_key.clone());
if let Some(previous) = seen_transitions.insert(key, index + 1) {
errors.push(format!(
"{} duplicates transition #{} for role={:?}, state={}, event={}",
line_hint, previous, role, transition.state, event_key
));
}
for action in &transition.actions {
if let Err(err) = self.parse_action(action.clone()) {
errors.push(format!("{} has invalid action: {}", line_hint, err));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(Self::validation_error(errors))
}
}
fn convert_transition(&self, yaml: YamlTransition) -> Result<(StateKey, Transition)> {
let role = match yaml.role.to_lowercase().as_str() {
"uac" => Role::UAC,
"uas" => Role::UAS,
"both" => Role::Both,
"server" => Role::UAS, _ => {
return Err(SessionError::InternalError(format!(
"Invalid role: {}",
yaml.role
)))
}
};
let is_wildcard = yaml.state == "Any" || yaml.state == "*";
let state = if is_wildcard {
CallState::Idle } else {
self.parse_call_state(&yaml.state)?
};
let event = self.parse_event(yaml.event)?;
let key = StateKey {
role,
state,
event: event.clone(),
};
let guards = yaml
.guards
.into_iter()
.map(|g| self.parse_guard(g))
.collect::<Result<Vec<_>>>()?;
let actions = yaml
.actions
.into_iter()
.map(|a| self.parse_action(a))
.collect::<Result<Vec<_>>>()?;
let next_state = yaml
.next_state
.map(|s| self.parse_call_state(&s))
.transpose()?;
let condition_updates = ConditionUpdates {
dialog_established: yaml.conditions.dialog_established,
media_session_ready: yaml.conditions.media_session_ready,
sdp_negotiated: yaml.conditions.sdp_negotiated,
};
let publish_events = yaml
.publish
.into_iter()
.map(|e| self.parse_event_template(&e))
.collect::<Result<Vec<_>>>()?;
let transition = Transition {
guards,
actions,
next_state,
condition_updates,
publish_events,
};
if is_wildcard {
return Err(SessionError::InternalError(format!(
"WILDCARD_TRANSITION:{}:{}:{}",
serde_json::to_string(&role).unwrap_or_default(),
serde_json::to_string(&event).unwrap_or_default(),
serde_json::to_string(&transition).unwrap_or_default()
)));
}
Ok((key, transition))
}
fn parse_call_state(&self, state: &str) -> Result<CallState> {
match state {
"Idle" => Ok(CallState::Idle),
"Initiating" => Ok(CallState::Initiating),
"CancelPending" => Ok(CallState::CancelPending),
"Cancelling" => Ok(CallState::Cancelling),
"Ringing" => Ok(CallState::Ringing),
"Answering" => Ok(CallState::Answering),
"AnsweringHangupPending" => Ok(CallState::AnsweringHangupPending),
"EarlyMedia" => Ok(CallState::EarlyMedia),
"Active" => Ok(CallState::Active),
"HoldPending" => Ok(CallState::HoldPending),
"OnHold" => Ok(CallState::OnHold),
"Resuming" => Ok(CallState::Resuming),
"Bridged" => Ok(CallState::Bridged),
"Transferring" => Ok(CallState::Transferring),
"TransferringCall" => Ok(CallState::TransferringCall),
"Terminating" => Ok(CallState::Terminating),
"Terminated" => Ok(CallState::Terminated),
"Muted" => Ok(CallState::Muted),
"ConsultationCall" => Ok(CallState::ConsultationCall),
"Cancelled" => Ok(CallState::Cancelled),
"Registering" => Ok(CallState::Registering),
"Registered" => Ok(CallState::Registered),
"Unregistering" => Ok(CallState::Unregistering),
"Subscribing" => Ok(CallState::Subscribing),
"Subscribed" => Ok(CallState::Subscribed),
"Publishing" => Ok(CallState::Publishing),
"Authenticating" => Ok(CallState::Authenticating),
"Messaging" => Ok(CallState::Messaging),
_ if state.starts_with("Failed") => {
Ok(CallState::Failed(FailureReason::Other))
}
_ => Err(SessionError::InternalError(format!(
"Invalid call state: {}",
state
))),
}
}
fn parse_event(&self, event: YamlEvent) -> Result<EventType> {
match event {
YamlEvent::Simple(name) => self.parse_event_by_name(&name),
YamlEvent::Complex {
event_type,
parameters,
} => {
match event_type.as_str() {
"MakeCall" => {
let target = parameters
.get("target")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(EventType::MakeCall { target })
}
"IncomingCall" | "IncomingCallAutoAccept" => {
let from = parameters
.get("from")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let sdp = parameters
.get("sdp")
.and_then(|v| v.as_str())
.map(String::from);
if event_type == "IncomingCallAutoAccept" {
Ok(EventType::IncomingCallAutoAccept { from, sdp })
} else {
Ok(EventType::IncomingCall { from, sdp })
}
}
"SendEarlyMedia" => {
let sdp = parameters
.get("sdp")
.and_then(|v| v.as_str())
.map(String::from);
Ok(EventType::SendEarlyMedia { sdp })
}
"AuthRequired" => {
let status_code = parameters
.get("status_code")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u16;
let challenge = parameters
.get("challenge")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let method = parameters
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(EventType::AuthRequired {
status_code,
challenge,
method,
})
}
_ => self.parse_event_by_name(&event_type),
}
}
}
}
fn parse_event_by_name(&self, name: &str) -> Result<EventType> {
match name {
"MakeCall" => Ok(EventType::MakeCall {
target: String::new(),
}),
"AcceptCall" => Ok(EventType::AcceptCall),
"RejectCall" => Ok(EventType::RejectCall {
status: 0,
reason: String::new(),
}),
"RedirectCall" => Ok(EventType::RedirectCall {
status: 0,
contacts: Vec::new(),
}),
"SendEarlyMedia" => Ok(EventType::SendEarlyMedia { sdp: None }),
"AuthRequired" => Ok(EventType::AuthRequired {
status_code: 0,
challenge: String::new(),
method: String::new(),
}),
"SessionIntervalTooSmall" => Ok(EventType::SessionIntervalTooSmall { min_se_secs: 0 }),
"Registration401" => Ok(EventType::AuthRequired {
status_code: 401,
challenge: String::new(),
method: "REGISTER".to_string(),
}),
"HangupCall" => Ok(EventType::HangupCall),
"CancelCall" => Ok(EventType::CancelCall),
"HoldCall" => Ok(EventType::HoldCall),
"ResumeCall" => Ok(EventType::ResumeCall),
"DialogProgress" | "Dialog180Ringing" => Ok(EventType::Dialog180Ringing),
"Dialog183SessionProgress" => Ok(EventType::Dialog183SessionProgress),
"DialogEstablished" | "Dialog200OK" => Ok(EventType::Dialog200OK),
"DialogFailed" => Ok(EventType::Dialog4xxFailure(400)),
"Dialog4xxFailure" => Ok(EventType::Dialog4xxFailure(400)),
"Dialog5xxFailure" => Ok(EventType::Dialog5xxFailure(500)),
"Dialog6xxFailure" => Ok(EventType::Dialog6xxFailure(600)),
"Dialog487RequestTerminated" => Ok(EventType::Dialog487RequestTerminated),
"Dialog3xxRedirect" => Ok(EventType::Dialog3xxRedirect {
status: 0,
targets: Vec::new(),
}),
"ReinviteGlare" => Ok(EventType::ReinviteGlare),
"ReinviteReceived" => Ok(EventType::ReinviteReceived { sdp: None }),
"UpdateReceived" => Ok(EventType::UpdateReceived { sdp: None }),
"DialogACK" => Ok(EventType::DialogACK),
"DialogBYE" => Ok(EventType::DialogBYE),
"DialogCANCEL" => Ok(EventType::DialogCANCEL),
"DialogTimeout" => Ok(EventType::DialogTimeout),
"DialogTerminated" => Ok(EventType::DialogTerminated),
"InboundBYE" | "OutboundBYE" => Ok(EventType::DialogBYE),
"IncomingCall" => Ok(EventType::IncomingCall {
from: String::new(),
sdp: None,
}),
"IncomingCallAutoAccept" => Ok(EventType::IncomingCallAutoAccept {
from: String::new(),
sdp: None,
}),
"MediaReady" => Ok(EventType::MediaEvent("media_session_created".to_string())),
"MediaFlowing" => Ok(EventType::MediaEvent("media_flow_established".to_string())),
"MediaFailed" => Ok(EventType::MediaEvent("media_failed".to_string())),
"SDPNegotiated" => Ok(EventType::MediaEvent("sdp_negotiated".to_string())),
"CheckReadiness" => Ok(EventType::CheckConditions),
"PublishEstablished" => Ok(EventType::PublishCallEstablished),
"BridgeToSession" | "BridgeSessions" => Ok(EventType::BridgeSessions {
other_session: SessionId::new(),
}),
"TransferRequested" => Ok(EventType::TransferRequested {
refer_to: String::new(),
transfer_type: String::new(),
transaction_id: String::new(),
}),
"InternalProceedWithTransfer" => Ok(EventType::InternalProceedWithTransfer),
"InternalMakeTransferCall" => Ok(EventType::InternalMakeTransferCall),
"InternalTransferCallEstablished" => Ok(EventType::InternalTransferCallEstablished),
"StartRegistration" => Ok(EventType::StartRegistration),
"Registration200OK" => Ok(EventType::Registration200OK),
"RetryRegistration" => Ok(EventType::RetryRegistration),
"RefreshRegistration" => Ok(EventType::RefreshRegistration),
"RegistrationFailed" => Ok(EventType::RegistrationFailed(0)),
"StartUnregistration" => Ok(EventType::StartUnregistration),
"Unregistration200OK" => Ok(EventType::Unregistration200OK),
"UnregistrationFailed" => Ok(EventType::UnregistrationFailed),
"UnregisterRequest" => Ok(EventType::UnregisterRequest),
"RegistrationExpired" => Ok(EventType::RegistrationExpired),
"StartSubscription" => Ok(EventType::StartSubscription),
"ReceiveNOTIFY" => Ok(EventType::ReceiveNOTIFY),
"SendNOTIFY" => Ok(EventType::SendNOTIFY),
"SubscriptionAccepted" => Ok(EventType::SubscriptionAccepted),
"SubscriptionFailed" => Ok(EventType::SubscriptionFailed(0)),
"SubscriptionExpired" => Ok(EventType::SubscriptionExpired),
"UnsubscribeRequest" => Ok(EventType::UnsubscribeRequest),
"SendMessage" => Ok(EventType::SendMessage),
"ReceiveMESSAGE" => Ok(EventType::ReceiveMESSAGE),
"MessageDelivered" => Ok(EventType::MessageDelivered),
"MessageFailed" => Ok(EventType::MessageFailed(0)),
"SendOutboundInvite" => Ok(EventType::SendOutboundInvite),
"SendOutboundReInvite" => Ok(EventType::SendOutboundReInvite),
"SendOutboundBye" => Ok(EventType::SendOutboundBye),
"SendOutboundCancel" => Ok(EventType::SendOutboundCancel),
"SendOutboundRefer" => Ok(EventType::SendOutboundRefer),
"SendOutboundNotify" => Ok(EventType::SendOutboundNotify),
"SendOutboundInfo" => Ok(EventType::SendOutboundInfo),
"SendOutboundUpdate" => Ok(EventType::SendOutboundUpdate),
"SendOutboundMessage" => Ok(EventType::SendOutboundMessage),
"SendOutboundOptions" => Ok(EventType::SendOutboundOptions),
"SendOutboundSubscribe" => Ok(EventType::SendOutboundSubscribe),
"SendOutboundRegister" => Ok(EventType::SendOutboundRegister),
_ => Err(SessionError::InternalError(format!(
"Unknown YAML event '{}': add a matching arm in \
state_table/yaml_loader.rs::parse_event_by_name or remove \
the YAML reference.",
name
))),
}
}
fn parse_guard(&self, guard: YamlGuard) -> Result<Guard> {
match guard {
YamlGuard::Simple(name) => self.parse_guard_by_name(&name),
YamlGuard::Complex { guard_type, .. } => self.parse_guard_by_name(&guard_type),
}
}
fn parse_guard_by_name(&self, name: &str) -> Result<Guard> {
match name {
"HasLocalSDP" => Ok(Guard::HasLocalSDP),
"HasRemoteSDP" => Ok(Guard::HasRemoteSDP),
"DialogEstablished" => Ok(Guard::DialogEstablished),
"MediaReady" => Ok(Guard::MediaReady),
"SDPNegotiated" => Ok(Guard::SDPNegotiated),
"AllConditionsMet" | "all_conditions_met" => Ok(Guard::AllConditionsMet),
"IsIdle" => Ok(Guard::IsIdle),
"InActiveCall" => Ok(Guard::InActiveCall),
"IsRegistered" => Ok(Guard::IsRegistered),
"IsSubscribed" => Ok(Guard::IsSubscribed),
"HasActiveSubscription" => Ok(Guard::HasActiveSubscription),
"HasPendingReinvite" => Ok(Guard::HasPendingReinvite),
"OtherSessionActive" => Ok(Guard::Custom(name.to_string())),
_ => {
debug!("Unknown guard '{}', treating as custom", name);
Ok(Guard::Custom(name.to_string()))
}
}
}
fn parse_action(&self, action: YamlAction) -> Result<Action> {
match action {
YamlAction::Simple(name) => self.parse_action_by_name(&name),
YamlAction::Complex {
action_type,
parameters,
} => {
match action_type.as_str() {
"SendSIPResponse" => {
let code = parameters
.get("code")
.and_then(|v| v.as_u64())
.unwrap_or(200) as u16;
let reason = parameters
.get("reason")
.and_then(|v| v.as_str())
.unwrap_or("OK")
.to_string();
Ok(Action::SendSIPResponse(code, reason))
}
"SetCondition" => {
let condition = parameters
.get("condition")
.and_then(|v| v.as_str())
.unwrap_or("dialog_established");
let value = parameters
.get("value")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let cond = match condition {
"dialog_established" => Condition::DialogEstablished,
"media_session_ready" => Condition::MediaSessionReady,
"sdp_negotiated" => Condition::SDPNegotiated,
_ => {
return Err(SessionError::InternalError(format!(
"Invalid condition: {}",
condition
)))
}
};
Ok(Action::SetCondition(cond, value))
}
_ => self.parse_action_by_name(&action_type),
}
}
}
}
fn parse_action_by_name(&self, name: &str) -> Result<Action> {
match name {
"CreateDialog" => Ok(Action::CreateDialog),
"GenerateLocalSDP" => Ok(Action::GenerateLocalSDP),
"SendINVITE" | "TriggerDialogINVITE" => Ok(Action::SendINVITE),
"SendACK" => Ok(Action::SendACK),
"SendBYE" => Ok(Action::SendBYE),
"SendRejectResponse" => Ok(Action::SendRejectResponse),
"SendRedirectResponse" => Ok(Action::SendRedirectResponse),
"RetryWithContact" => Ok(Action::RetryWithContact),
"ScheduleReinviteRetry" => Ok(Action::ScheduleReinviteRetry),
"ClearPendingReinvite" => Ok(Action::ClearPendingReinvite),
"SendCANCEL" | "SendCANCELWithOptions" => Ok(Action::SendCANCELWithOptions),
"SendReINVITE" => Ok(Action::SendReINVITE),
"CreateMediaSession" => Ok(Action::CreateMediaSession),
"StartMediaSession" => Ok(Action::StartMediaSession),
"StopMediaSession" | "StopMedia" => Ok(Action::CleanupMedia),
"NegotiateSDPAsUAC" => Ok(Action::NegotiateSDPAsUAC),
"NegotiateSDPAsUAS" => Ok(Action::NegotiateSDPAsUAS),
"PrepareEarlyMediaSDP" => Ok(Action::PrepareEarlyMediaSDP),
"SwitchToPassThroughOnActive" => Ok(Action::SwitchToPassThroughOnActive),
"StoreAuthChallenge" => Ok(Action::StoreAuthChallenge),
"SendINVITEWithAuth" => Ok(Action::SendINVITEWithAuth),
"SendINVITEWithBumpedSessionExpires" => Ok(Action::SendINVITEWithBumpedSessionExpires),
"SendREGISTERWithAuth" => Ok(Action::SendREGISTERWithAuth),
"SendRequestWithAuth" => Ok(Action::SendRequestWithAuth),
"SuspendMedia" => Ok(Action::Custom("SuspendMedia".to_string())),
"ResumeMedia" => Ok(Action::Custom("ResumeMedia".to_string())),
"StoreLocalSDP" => Ok(Action::StoreLocalSDP),
"StoreRemoteSDP" => Ok(Action::StoreRemoteSDP),
"StoreNegotiatedConfig" => Ok(Action::StoreNegotiatedConfig),
"TriggerCallEstablished" | "PublishEstablished" => Ok(Action::TriggerCallEstablished),
"TriggerCallTerminated" => Ok(Action::TriggerCallTerminated),
"StartDialogCleanup" => Ok(Action::StartDialogCleanup),
"StartMediaCleanup" => Ok(Action::StartMediaCleanup),
"CleanupDialog" => Ok(Action::CleanupDialog),
"CleanupMedia" => Ok(Action::CleanupMedia),
"SendREGISTER" => Ok(Action::SendREGISTER),
"SendUnREGISTER" | "SendREGISTERWithExpires0" => Ok(Action::SendUnREGISTER),
"ProcessRegistrationResponse" => Ok(Action::ProcessRegistrationResponse),
"SendSUBSCRIBE" => Ok(Action::SendSUBSCRIBE),
"ProcessNOTIFY" => Ok(Action::ProcessNOTIFY),
"SendNOTIFY" | "SendNOTIFYWithOptions" => Ok(Action::SendNOTIFYWithOptions),
"SendMESSAGE" => Ok(Action::SendMESSAGE),
"ProcessMESSAGE" => Ok(Action::ProcessMESSAGE),
"HoldOriginalCall" | "HoldCurrentCall" => Ok(Action::HoldCurrentCall),
"ResumeOriginalCall" => Ok(Action::RestoreMediaFlow),
"SendReferAccepted" => Ok(Action::SendReferAccepted),
"SendRefer100Trying" => Ok(Action::SendRefer100Trying),
"SendTransferNotifyRinging" => Ok(Action::SendTransferNotifyRinging),
"SendTransferNotifySuccess" => Ok(Action::SendTransferNotifySuccess),
"SendTransferNotifyFailure" => Ok(Action::SendTransferNotifyFailure),
"CheckReadiness" => Ok(Action::Custom("CheckReadiness".to_string())),
"SendINVITEWithOptions" => Ok(Action::SendINVITEWithOptions),
"SendReINVITEWithOptions" => Ok(Action::SendReINVITEWithOptions),
"SendREGISTERWithOptions" => Ok(Action::SendREGISTERWithOptions),
"SendSUBSCRIBEWithOptions" => Ok(Action::SendSUBSCRIBEWithOptions),
"SendMESSAGEWithOptions" => Ok(Action::SendMESSAGEWithOptions),
"SendBYEWithOptions" => Ok(Action::SendBYEWithOptions),
"SendREFERWithOptions" => Ok(Action::SendREFERWithOptions),
"SendINFOWithOptions" => Ok(Action::SendINFOWithOptions),
"SendUPDATEWithOptions" => Ok(Action::SendUPDATEWithOptions),
"SendOPTIONSWithOptions" => Ok(Action::SendOPTIONSWithOptions),
"ClearPendingINVITEOptions" => Ok(Action::ClearPendingINVITEOptions),
"ClearPendingReINVITEOptions" => Ok(Action::ClearPendingReINVITEOptions),
"ClearPendingREGISTEROptions" => Ok(Action::ClearPendingREGISTEROptions),
"ClearPendingSUBSCRIBEOptions" => Ok(Action::ClearPendingSUBSCRIBEOptions),
"ClearPendingMESSAGEOptions" => Ok(Action::ClearPendingMESSAGEOptions),
"ClearPendingNOTIFYOptions" => Ok(Action::ClearPendingNOTIFYOptions),
"ClearPendingBYEOptions" => Ok(Action::ClearPendingBYEOptions),
"ClearPendingCANCELOptions" => Ok(Action::ClearPendingCANCELOptions),
"ClearPendingREFEROptions" => Ok(Action::ClearPendingREFEROptions),
"ClearPendingINFOOptions" => Ok(Action::ClearPendingINFOOptions),
"ClearPendingUPDATEOptions" => Ok(Action::ClearPendingUPDATEOptions),
"ClearPendingOPTIONSOptions" => Ok(Action::ClearPendingOPTIONSOptions),
_ => Err(SessionError::InternalError(format!(
"Unknown YAML action '{}': add a matching arm in \
state_table/yaml_loader.rs::parse_action_by_name or remove \
the YAML reference.",
name
))),
}
}
fn parse_event_template(&self, name: &str) -> Result<EventTemplate> {
match name {
"SessionCreated" => Ok(EventTemplate::SessionCreated),
"StateChanged" => Ok(EventTemplate::StateChanged),
"CallEstablished" => Ok(EventTemplate::CallEstablished),
"CallTerminated" => Ok(EventTemplate::CallTerminated),
"CallFailed" => Ok(EventTemplate::CallFailed),
"CallCancelled" => Ok(EventTemplate::CallCancelled),
"MediaFlowEstablished" => Ok(EventTemplate::MediaFlowEstablished),
"CallRinging" => Ok(EventTemplate::Custom("CallRinging".to_string())),
"CallOnHold" => Ok(EventTemplate::CallOnHold),
"CallResumed" => Ok(EventTemplate::CallResumed),
"SessionsBridged" => Ok(EventTemplate::Custom("SessionsBridged".to_string())),
"TransferSucceeded" => Ok(EventTemplate::Custom("TransferSucceeded".to_string())),
_ => Ok(EventTemplate::Custom(name.to_string())),
}
}
}
impl Default for YamlTableLoader {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_yaml() {
let yaml = r#"
version: "1.0"
transitions:
- role: UAC
state: Idle
event: MakeCall
next_state: Initiating
actions:
- SendINVITE
publish:
- SessionCreated
"#;
let mut loader = YamlTableLoader::new();
loader.load_from_string(yaml).expect("Failed to load YAML");
let table = loader.build().expect("Failed to build table");
let key = StateKey {
role: Role::UAC,
state: CallState::Idle,
event: EventType::MakeCall {
target: String::new(),
},
};
assert!(table.has_transition(&key));
}
#[test]
fn test_complex_event_parsing() {
let yaml = r#"
version: "1.0"
transitions:
- role: UAC
state: Idle
event:
type: MakeCall
target: "sip:bob@example.com"
next_state: Initiating
"#;
let mut loader = YamlTableLoader::new();
loader.load_from_string(yaml).expect("Failed to load YAML");
loader.build().expect("Failed to build table");
}
#[test]
fn test_condition_updates() {
let yaml = r#"
version: "1.0"
transitions:
- role: Both
state: Active
event: CheckReadiness
conditions:
dialog_established: true
media_session_ready: true
sdp_negotiated: true
"#;
let mut loader = YamlTableLoader::new();
loader.load_from_string(yaml).expect("Failed to load YAML");
let table = loader.build().expect("Failed to build table");
let key = StateKey {
role: Role::Both,
state: CallState::Active,
event: EventType::CheckConditions,
};
let transition = table.get_transition(&key).expect("Transition not found");
assert!(transition
.condition_updates
.dialog_established
.unwrap_or(false));
}
#[test]
fn default_yaml_loads_with_no_unknown_actions() {
YamlTableLoader::load_embedded_default().expect(
"embedded default.yaml failed to load cleanly — \
check for dead YAML action names or missing \
parse_action_by_name arms",
);
}
#[test]
fn media_session_id_is_alias_of_media_core_dialog_id() {
let _: super::super::types::MediaSessionId = rvoip_media_core::DialogId::new_v4();
}
#[test]
fn parse_action_by_name_hard_errors_on_unknown_names() {
let loader = YamlTableLoader::new();
let err = loader
.parse_action_by_name("ThisNameDoesNotExist42")
.expect_err("unknown YAML action must be a hard error, not Custom fallback");
let msg = format!("{:?}", err);
assert!(
msg.contains("Unknown YAML action"),
"unexpected error for unknown action: {}",
msg,
);
}
}