use super::{CaptureOutput, WorkflowStep};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum HandlerStrategy {
#[default]
Recovery,
Fallback,
Cleanup,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailureHandlerConfig {
pub commands: Vec<HandlerCommand>,
#[serde(default)]
pub strategy: HandlerStrategy,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
#[serde(default)]
pub capture: HashMap<String, String>,
#[serde(default = "default_fail")]
pub fail_workflow: bool,
#[serde(default)]
pub handler_failure_fatal: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandlerCommand {
#[serde(skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude: Option<String>,
#[serde(default)]
pub continue_on_error: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum OnFailureConfig {
IgnoreErrors(bool),
SingleCommand(String),
MultipleCommands(Vec<String>),
Detailed(FailureHandlerConfig),
Advanced {
#[serde(skip_serializing_if = "Option::is_none")]
shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
claude: Option<String>,
#[serde(default = "default_fail")]
fail_workflow: bool,
#[serde(default = "default_retry_original")]
retry_original: bool,
#[serde(default = "default_retries", alias = "max_attempts")]
max_retries: u32,
},
FailControl {
#[serde(default)]
fail_workflow: bool,
},
Handler(Box<WorkflowStep>),
}
fn default_fail() -> bool {
false }
fn default_retries() -> u32 {
1
}
fn default_retry_original() -> bool {
false }
impl OnFailureConfig {
pub fn should_fail_workflow(&self) -> bool {
match self {
OnFailureConfig::IgnoreErrors(false) => true,
OnFailureConfig::IgnoreErrors(true) => false,
OnFailureConfig::SingleCommand(_) => false,
OnFailureConfig::MultipleCommands(_) => false,
OnFailureConfig::Detailed(config) => config.fail_workflow,
OnFailureConfig::Advanced { fail_workflow, .. } => *fail_workflow,
OnFailureConfig::FailControl { fail_workflow } => *fail_workflow,
OnFailureConfig::Handler(_) => false, }
}
pub fn handler_commands(&self) -> Vec<HandlerCommand> {
match self {
OnFailureConfig::SingleCommand(cmd) => {
vec![if cmd.starts_with("/") {
HandlerCommand {
claude: Some(cmd.clone()),
shell: None,
continue_on_error: false,
}
} else {
HandlerCommand {
shell: Some(cmd.clone()),
claude: None,
continue_on_error: false,
}
}]
}
OnFailureConfig::MultipleCommands(cmds) => cmds
.iter()
.map(|cmd| {
if cmd.starts_with("/") {
HandlerCommand {
claude: Some(cmd.clone()),
shell: None,
continue_on_error: false,
}
} else {
HandlerCommand {
shell: Some(cmd.clone()),
claude: None,
continue_on_error: false,
}
}
})
.collect(),
OnFailureConfig::Detailed(config) => config.commands.clone(),
OnFailureConfig::Advanced { shell, claude, .. } => {
let mut commands = Vec::new();
if let Some(sh) = shell {
commands.push(HandlerCommand {
shell: Some(sh.clone()),
claude: None,
continue_on_error: false,
});
}
if let Some(cl) = claude {
commands.push(HandlerCommand {
claude: Some(cl.clone()),
shell: None,
continue_on_error: false,
});
}
commands
}
_ => Vec::new(),
}
}
pub fn handler(&self) -> Option<WorkflowStep> {
match self {
OnFailureConfig::Advanced { shell, claude, .. } => {
if shell.is_some() || claude.is_some() {
Some(WorkflowStep {
name: None,
shell: shell.clone(),
claude: claude.clone(),
test: None,
foreach: None,
write_file: None,
command: None,
handler: None,
capture: None,
capture_format: None,
capture_streams: Default::default(),
output_file: None,
timeout: None,
capture_output: CaptureOutput::Disabled,
on_failure: None,
retry: None,
on_success: None,
on_exit_code: Default::default(),
commit_required: false,
auto_commit: false,
commit_config: None,
working_dir: None,
env: Default::default(),
validate: None,
step_validate: None,
skip_validation: false,
validation_timeout: None,
ignore_validation_failure: false,
when: None,
})
} else {
None
}
}
OnFailureConfig::Handler(step) => Some((**step).clone()),
_ => None,
}
}
pub fn should_retry(&self) -> bool {
self.max_retries() > 0
}
pub fn max_retries(&self) -> u32 {
match self {
OnFailureConfig::Advanced { max_retries, .. } => *max_retries,
_ => 0,
}
}
pub fn strategy(&self) -> HandlerStrategy {
match self {
OnFailureConfig::Detailed(config) => config.strategy.clone(),
_ => HandlerStrategy::Recovery,
}
}
pub fn handler_failure_fatal(&self) -> bool {
match self {
OnFailureConfig::Detailed(config) => config.handler_failure_fatal,
_ => false,
}
}
pub fn handler_timeout(&self) -> Option<u64> {
match self {
OnFailureConfig::Detailed(config) => config.timeout,
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ignore_errors() {
let yaml = "true";
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
assert!(!config.should_fail_workflow());
let yaml = "false";
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.should_fail_workflow());
}
#[test]
fn test_parse_fail_control() {
let yaml = "fail_workflow: true";
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.should_fail_workflow());
let yaml = "fail_workflow: false";
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
assert!(!config.should_fail_workflow());
}
#[test]
fn test_parse_handler() {
let yaml = r#"
shell: "echo 'Handling error'"
"#;
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.handler().is_some());
assert!(!config.should_fail_workflow()); }
#[test]
fn test_parse_advanced() {
let yaml = r#"
shell: "fix-error"
fail_workflow: true
retry_original: true
max_retries: 3
"#;
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.handler().is_some());
assert!(config.should_fail_workflow());
assert!(config.should_retry());
assert_eq!(config.max_retries(), 3);
}
#[test]
fn test_max_attempts_implies_retry() {
let yaml = r#"
claude: "/fix-error"
max_attempts: 3
fail_workflow: false
"#;
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.handler().is_some());
assert!(!config.should_fail_workflow());
assert!(config.should_retry());
assert_eq!(config.max_retries(), 3);
}
#[test]
fn test_single_command() {
let yaml = r#""echo 'Handling error'""#;
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
let commands = config.handler_commands();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].shell, Some("echo 'Handling error'".to_string()));
assert!(commands[0].claude.is_none());
assert!(!config.should_fail_workflow());
}
#[test]
fn test_multiple_commands() {
let yaml = r#"
- "npm cache clean --force"
- "npm install"
- "/fix-errors"
"#;
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
let commands = config.handler_commands();
assert_eq!(commands.len(), 3);
assert_eq!(
commands[0].shell,
Some("npm cache clean --force".to_string())
);
assert_eq!(commands[1].shell, Some("npm install".to_string()));
assert_eq!(commands[2].claude, Some("/fix-errors".to_string()));
}
#[test]
fn test_detailed_config() {
let yaml = r#"
strategy: recovery
commands:
- shell: "cleanup.sh"
continue_on_error: true
- claude: "/fix-issue"
timeout: 300
fail_workflow: false
handler_failure_fatal: true
"#;
let config: OnFailureConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.strategy(), HandlerStrategy::Recovery);
assert_eq!(config.handler_timeout(), Some(300));
assert!(config.handler_failure_fatal());
assert!(!config.should_fail_workflow());
let commands = config.handler_commands();
assert_eq!(commands.len(), 2);
assert!(commands[0].continue_on_error);
}
}