use indexmap::IndexMap;
use regex::Regex;
pub use rhei_core::ast::{CallbackRef, StateName, TransitionRule};
use rhei_core::ast::{Rhei, Structure, Task, TaskId, TaskIdSegment};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ValidationReport {
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub help: Vec<String>,
}
impl ValidationReport {
pub fn ok() -> Self {
Self { errors: Vec::new(), warnings: Vec::new(), help: Vec::new() }
}
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
pub fn extend(&mut self, other: ValidationReport) {
self.errors.extend(other.errors);
self.warnings.extend(other.warnings);
self.help.extend(other.help);
}
}
pub trait Validate {
fn validate(&self) -> ValidationReport;
}
impl Validate for () {
fn validate(&self) -> ValidationReport {
ValidationReport::ok()
}
}
#[derive(Debug)]
pub enum StateMachineLoadError {
Io(std::io::Error),
Yaml(serde_yaml::Error),
Invalid(String),
}
impl std::fmt::Display for StateMachineLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StateMachineLoadError::Io(e) => write!(f, "I/O error: {e}"),
StateMachineLoadError::Yaml(e) => write!(f, "YAML error: {e}"),
StateMachineLoadError::Invalid(message) => {
write!(f, "invalid state machine: {message}")
}
}
}
}
impl std::error::Error for StateMachineLoadError {}
impl From<std::io::Error> for StateMachineLoadError {
fn from(e: std::io::Error) -> Self {
StateMachineLoadError::Io(e)
}
}
impl From<serde_yaml::Error> for StateMachineLoadError {
fn from(e: serde_yaml::Error) -> Self {
StateMachineLoadError::Yaml(e)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct StateArtifactDef {
pub name: String,
pub path: String,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub optional: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(transparent)]
pub struct AgentConfig(pub String);
impl AgentConfig {
pub fn id(&self) -> &str {
&self.0
}
}
impl From<String> for AgentConfig {
fn from(id: String) -> Self {
AgentConfig(id)
}
}
impl From<&str> for AgentConfig {
fn from(id: &str) -> Self {
AgentConfig(id.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
pub struct CustomAgentProfile {
pub command: Vec<String>,
#[serde(default)]
pub prompt_flag: Option<String>,
#[serde(default)]
pub model_flag: Option<String>,
#[serde(default)]
pub stdin_prompt: bool,
#[serde(default)]
pub intervene_stdin: bool,
#[serde(default)]
pub timeout: Option<String>,
#[serde(default)]
pub mcp_flag: Option<String>,
#[serde(default)]
pub mcp_config_flag: Option<String>,
#[serde(default)]
pub skill_flag: Option<String>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub modes: IndexMap<String, Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
pub struct McpServerProfile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup_timeout: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct SkillProfile {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum StateMcpEntry {
Id(String),
Object(StateMcpEntryObject),
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
pub struct StateMcpEntryObject {
pub id: String,
#[serde(default)]
pub optional: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transport: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub startup_timeout: Option<String>,
}
impl StateMcpEntry {
pub fn id(&self) -> &str {
match self {
StateMcpEntry::Id(id) => id,
StateMcpEntry::Object(obj) => &obj.id,
}
}
pub fn is_optional(&self) -> bool {
match self {
StateMcpEntry::Id(_) => false,
StateMcpEntry::Object(obj) => obj.optional,
}
}
pub fn is_inline(&self) -> bool {
match self {
StateMcpEntry::Id(_) => false,
StateMcpEntry::Object(obj) => obj.command.is_some() || obj.url.is_some(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(untagged)]
pub enum StateSkillEntry {
Id(String),
Object(StateSkillEntryObject),
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
pub struct StateSkillEntryObject {
pub id: String,
#[serde(default)]
pub optional: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl StateSkillEntry {
pub fn id(&self) -> &str {
match self {
StateSkillEntry::Id(id) => id,
StateSkillEntry::Object(obj) => &obj.id,
}
}
pub fn is_optional(&self) -> bool {
match self {
StateSkillEntry::Id(_) => false,
StateSkillEntry::Object(obj) => obj.optional,
}
}
pub fn is_inline(&self) -> bool {
match self {
StateSkillEntry::Id(_) => false,
StateSkillEntry::Object(obj) => obj.path.is_some(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExecutionTarget {
pub agent: String,
pub mode: Option<String>,
pub provider: Option<String>,
pub model: String,
}
impl ExecutionTarget {
pub fn slug(&self) -> String {
let mut slug = String::new();
let mut last_was_dash = false;
for ch in self.selector().chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
last_was_dash = false;
} else if matches!(ch, '.' | '_' | '-') {
slug.push(ch);
last_was_dash = ch == '-';
} else if !last_was_dash {
slug.push('-');
last_was_dash = true;
}
}
slug.trim_matches('-').to_string()
}
pub fn selector(&self) -> String {
let mut selector = self.agent.clone();
if let Some(mode) = &self.mode {
selector.push('[');
selector.push_str(mode);
selector.push(']');
}
selector.push(':');
if let Some(provider) = &self.provider {
selector.push_str(provider);
selector.push(':');
}
selector.push_str(&self.model);
selector
}
}
pub fn execution_target_example(selector: &str) -> String {
let head = selector.split(':').next().unwrap_or_default().trim();
let agent = head.split('[').next().unwrap_or_default().trim();
let agent = if agent.is_empty() { "<agent>" } else { agent };
format!("{agent}[yolo]:openai:gpt-5.5")
}
fn execution_target_repair(selector: &str) -> String {
let head = selector.split(':').next().unwrap_or_default().trim();
let head = if head.is_empty() { "<agent>" } else { head };
format!(
"write it as '{head}:<model>' or '{head}:<provider>:<model>' \
(a mode goes in brackets: '{head}[<mode>]:<provider>:<model>'). \
A selector carrying a mode must be quoted in the shell, e.g. \
'{}'",
execution_target_example(selector)
)
}
pub fn parse_execution_target(selector: &str) -> Result<ExecutionTarget, String> {
let selector = selector.trim();
if selector.is_empty() {
return Err(
"execution target selector must not be empty; write it as '<agent>:<model>', \
e.g. 'claude-code:claude-opus-4-7'"
.to_string(),
);
}
let parts: Vec<&str> = selector.split(':').collect();
if parts.len() != 2 && parts.len() != 3 {
let problem = if parts.len() == 1 {
"is missing the model"
} else {
"has too many ':'-separated segments"
};
return Err(format!(
"execution target selector '{selector}' {problem}; {}",
execution_target_repair(selector)
));
}
let head = parts[0].trim();
let (provider, model) = if parts.len() == 2 {
(None, parts[1].trim())
} else {
(Some(parts[1].trim()), parts[2].trim())
};
if model.is_empty() {
return Err(format!(
"execution target selector '{selector}' is missing the model after ':'; {}",
execution_target_repair(selector)
));
}
if let Some(provider) = provider {
if provider.is_empty() {
return Err(format!(
"execution target selector '{selector}' is missing the provider between the \
agent and the model; {}",
execution_target_repair(selector)
));
}
}
let (agent, mode) = if let Some(open) = head.find('[') {
if !head.ends_with(']') {
return Err(format!(
"execution target selector '{selector}' has an unterminated mode segment; \
close the bracket and {}",
execution_target_repair(selector)
));
}
let agent = head[..open].trim();
let mode = head[open + 1..head.len() - 1].trim();
if agent.is_empty() {
return Err(format!(
"execution target selector '{selector}' is missing the agent before '['; {}",
execution_target_repair(selector)
));
}
if mode.is_empty() {
return Err(format!(
"execution target selector '{selector}' has an empty '[]' mode; name a mode \
the agent declares, or drop the brackets entirely"
));
}
if mode.contains('[') || mode.contains(']') {
return Err(format!(
"execution target selector '{selector}' contains nested mode brackets; a mode \
is a single bracketed name, e.g. 'claude-code[yolo]:claude-opus-4-7'"
));
}
(agent, Some(mode))
} else {
let agent = head.trim();
if agent.is_empty() {
return Err(format!(
"execution target selector '{selector}' is missing the agent; {}",
execution_target_repair(selector)
));
}
if agent.contains(']') {
return Err(format!(
"execution target selector '{selector}' contains an unexpected ']'; a mode \
must open with '[' too, e.g. 'claude-code[yolo]:claude-opus-4-7'"
));
}
(agent, None)
};
Ok(ExecutionTarget {
agent: agent.to_string(),
mode: mode.map(str::to_string),
provider: provider.map(str::to_string),
model: model.to_string(),
})
}