use serde::{Deserialize, Serialize};
use std::collections::HashMap;
fn default_true() -> bool {
true
}
fn default_cache_duration() -> u64 {
300
}
#[derive(Debug, Clone, PartialEq)]
pub enum CommandArg {
Literal(String),
Variable(String),
}
impl Serialize for CommandArg {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
CommandArg::Literal(s) => serializer.serialize_str(s),
CommandArg::Variable(var) => serializer.serialize_str(&format!("${{{var}}}")),
}
}
}
impl<'de> Deserialize<'de> for CommandArg {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(CommandArg::parse(&s))
}
}
impl CommandArg {
#[must_use]
pub fn is_variable(&self) -> bool {
matches!(self, CommandArg::Variable(_))
}
#[must_use]
pub fn resolve(&self, variables: &HashMap<String, String>) -> String {
match self {
CommandArg::Literal(s) => s.clone(),
CommandArg::Variable(var) => variables.get(var).cloned().unwrap_or_else(|| {
format!("${var}")
}),
}
}
#[must_use]
pub fn parse(s: &str) -> Self {
if s.starts_with("${") && s.ends_with('}') {
CommandArg::Variable(s[2..s.len() - 1].to_string())
} else if let Some(var) = s.strip_prefix('$') {
CommandArg::Variable(var.to_string())
} else {
CommandArg::Literal(s.to_string())
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Command {
pub name: String,
#[serde(default)]
pub args: Vec<CommandArg>,
#[serde(default)]
pub options: HashMap<String, serde_json::Value>,
#[serde(default)]
pub metadata: CommandMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outputs: Option<HashMap<String, OutputDeclaration>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub analysis: Option<AnalysisConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AnalysisConfig {
#[serde(default)]
pub force_refresh: bool,
#[serde(default = "default_cache_duration")]
pub max_cache_age: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct CommandMetadata {
pub retries: Option<u32>,
pub timeout: Option<u64>,
pub continue_on_error: Option<bool>,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub commit_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub analysis: Option<AnalysisConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OutputDeclaration {
pub file_pattern: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TestDebugConfig {
pub claude: String,
#[serde(default = "default_max_attempts")]
pub max_attempts: u32,
#[serde(default)]
pub fail_workflow: bool,
#[serde(default = "default_true")]
pub commit_required: bool,
}
fn default_max_attempts() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ForeachConfig {
#[serde(rename = "foreach")]
pub input: ForeachInput,
#[serde(default)]
pub parallel: ParallelConfig,
#[serde(rename = "do")]
pub do_block: Vec<Box<WorkflowStepCommand>>,
#[serde(default)]
pub continue_on_error: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_items: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ForeachInput {
Command(String),
List(Vec<String>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum ParallelConfig {
Boolean(bool),
Count(usize),
}
impl Default for ParallelConfig {
fn default() -> Self {
ParallelConfig::Boolean(false)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TestCommand {
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_failure: Option<TestDebugConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum WorkflowCommand {
Simple(String),
Structured(Box<Command>),
WorkflowStep(Box<WorkflowStepCommand>),
SimpleObject(SimpleCommand),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SimpleCommand {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit_required: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub args: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub analysis: Option<AnalysisConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WriteFileConfig {
pub path: String,
pub content: String,
#[serde(default)]
pub format: WriteFileFormat,
#[serde(default = "default_file_mode")]
pub mode: String,
#[serde(default)]
pub create_dirs: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum WriteFileFormat {
#[default]
Text,
Json,
Yaml,
}
fn default_file_mode() -> String {
"0644".to_string()
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct WorkflowStepCommand {
#[serde(skip_serializing_if = "Option::is_none")]
pub claude: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub analyze: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test: Option<TestCommand>,
#[serde(skip_serializing_if = "Option::is_none")]
pub foreach: Option<ForeachConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub write_file: Option<WriteFileConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(default)]
pub commit_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub analysis: Option<AnalysisConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outputs: Option<HashMap<String, OutputDeclaration>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capture_output: Option<CaptureOutputConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_failure: Option<TestDebugConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_success: Option<Box<WorkflowStepCommand>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub validate: Option<crate::cook::workflow::validation::ValidationConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub when: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capture_format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capture_streams: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_file: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum CaptureOutputConfig {
Boolean(bool),
Variable(String),
}
impl<'de> Deserialize<'de> for WorkflowStepCommand {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Helper {
claude: Option<String>,
shell: Option<String>,
analyze: Option<HashMap<String, serde_json::Value>>,
test: Option<TestCommand>,
foreach: Option<ForeachConfig>,
write_file: Option<WriteFileConfig>,
id: Option<String>,
#[serde(default)]
commit_required: bool,
analysis: Option<AnalysisConfig>,
outputs: Option<HashMap<String, OutputDeclaration>>,
#[serde(default)]
capture_output: Option<CaptureOutputConfig>,
on_failure: Option<TestDebugConfig>,
on_success: Option<Box<WorkflowStepCommand>>,
validate: Option<crate::cook::workflow::validation::ValidationConfig>,
timeout: Option<u64>,
when: Option<String>,
capture_format: Option<String>,
capture_streams: Option<String>,
output_file: Option<String>,
}
let helper = Helper::deserialize(deserializer)?;
let (shell, test, on_failure) = if let Some(test_cmd) = helper.test {
eprintln!("Warning: 'test:' command syntax is deprecated. Use 'shell:' with 'on_failure:' instead.");
eprintln!(
" Old: test: {{ command: \"{}\", on_failure: ... }}",
test_cmd.command
);
eprintln!(" New: shell: \"{}\"", test_cmd.command);
eprintln!(" on_failure: ...");
let shell_cmd = Some(test_cmd.command.clone());
let on_failure_config = test_cmd.on_failure.clone().or(helper.on_failure);
(shell_cmd, None, on_failure_config)
} else {
(helper.shell, None, helper.on_failure)
};
if helper.claude.is_none()
&& shell.is_none()
&& helper.analyze.is_none()
&& helper.foreach.is_none()
&& helper.write_file.is_none()
{
return Err(serde::de::Error::custom(
"WorkflowStepCommand must have 'claude', 'shell', 'analyze', 'foreach', or 'write_file' field",
));
}
Ok(WorkflowStepCommand {
claude: helper.claude,
shell,
analyze: helper.analyze,
test,
foreach: helper.foreach,
write_file: helper.write_file,
id: helper.id,
commit_required: helper.commit_required,
analysis: helper.analysis,
outputs: helper.outputs,
capture_output: helper.capture_output,
on_failure,
on_success: helper.on_success,
validate: helper.validate,
timeout: helper.timeout,
when: helper.when,
capture_format: helper.capture_format,
capture_streams: helper.capture_streams,
output_file: helper.output_file,
})
}
}
impl WorkflowCommand {
#[must_use]
pub fn to_command(&self) -> Command {
match self {
WorkflowCommand::Simple(s) => Command::from_string(s),
WorkflowCommand::Structured(c) => *c.clone(),
WorkflowCommand::WorkflowStep(step) => {
let step = &**step;
let command_str = extract_command_string(step);
let mut cmd = Command::from_string(&command_str);
apply_workflow_metadata(&mut cmd, step);
cmd
}
WorkflowCommand::SimpleObject(simple) => build_simple_command(simple),
}
}
}
fn extract_command_string(step: &WorkflowStepCommand) -> String {
if let Some(claude_cmd) = &step.claude {
claude_cmd.clone()
} else if let Some(shell_cmd) = &step.shell {
format!("shell {shell_cmd}")
} else if let Some(_analyze_attrs) = &step.analyze {
"analyze".to_string()
} else if let Some(test_cmd) = &step.test {
format!("test {}", test_cmd.command)
} else if let Some(foreach_config) = &step.foreach {
match &foreach_config.input {
ForeachInput::Command(cmd) => format!("foreach {cmd}"),
ForeachInput::List(items) => format!("foreach {} items", items.len()),
}
} else if let Some(write_file_config) = &step.write_file {
format!("write_file {}", write_file_config.path)
} else {
String::new()
}
}
fn apply_workflow_metadata(cmd: &mut Command, step: &WorkflowStepCommand) {
cmd.metadata.commit_required = step.commit_required;
if let Some(analysis) = &step.analysis {
cmd.analysis = Some(analysis.clone());
cmd.metadata.analysis = Some(analysis.clone());
}
cmd.id = step.id.clone();
cmd.outputs = step.outputs.clone();
}
fn build_simple_command(simple: &SimpleCommand) -> Command {
let mut cmd = Command::new(&simple.name);
if let Some(commit_required) = simple.commit_required {
cmd.metadata.commit_required = commit_required;
}
if let Some(args) = &simple.args {
for arg in args {
cmd.args.push(CommandArg::parse(arg));
}
}
if let Some(analysis) = simple.analysis.clone() {
cmd.analysis = Some(analysis.clone());
cmd.metadata.analysis = Some(analysis);
}
cmd
}
impl Command {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
args: Vec::new(),
options: HashMap::new(),
metadata: CommandMetadata::default(),
id: None,
outputs: None,
analysis: None,
}
}
#[must_use]
pub fn from_string(s: &str) -> Self {
match crate::config::command_parser::parse_command_string(s) {
Ok(cmd) => cmd,
Err(_) => {
let s = s.strip_prefix('/').unwrap_or(s);
Self::new(s)
}
}
}
pub fn with_arg(mut self, arg: impl Into<String>) -> Self {
let arg_str = arg.into();
self.args.push(CommandArg::parse(&arg_str));
self
}
pub fn with_option(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.options.insert(key.into(), value);
self
}
#[must_use]
pub fn with_retries(mut self, retries: u32) -> Self {
self.metadata.retries = Some(retries);
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: u64) -> Self {
self.metadata.timeout = Some(timeout);
self
}
#[must_use]
pub fn with_continue_on_error(mut self, continue_on_error: bool) -> Self {
self.metadata.continue_on_error = Some(continue_on_error);
self
}
pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.metadata.env.insert(key.into(), value.into());
self
}
}
impl<'de> Deserialize<'de> for Command {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct CommandHelper {
name: String,
#[serde(default)]
args: Vec<CommandArg>,
#[serde(default)]
options: HashMap<String, serde_json::Value>,
#[serde(default)]
metadata: CommandMetadata,
id: Option<String>,
outputs: Option<HashMap<String, OutputDeclaration>>,
commit_required: Option<bool>,
analysis: Option<AnalysisConfig>,
}
let helper = CommandHelper::deserialize(deserializer)?;
let mut metadata = helper.metadata;
if let Some(commit_required) = helper.commit_required {
metadata.commit_required = commit_required;
}
let analysis = helper.analysis.or(metadata.analysis.clone());
if analysis.is_some() {
metadata.analysis = analysis.clone();
}
Ok(Command {
name: helper.name,
args: helper.args,
options: helper.options,
metadata,
id: helper.id,
outputs: helper.outputs,
analysis,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::WorkflowConfig;
#[test]
fn test_command_creation() {
let cmd = Command::new("prodigy-code-review")
.with_arg("test")
.with_option("focus", serde_json::json!("security"))
.with_retries(3)
.with_timeout(300);
assert_eq!(cmd.name, "prodigy-code-review");
assert_eq!(cmd.args.len(), 1);
assert_eq!(cmd.args[0], CommandArg::Literal("test".to_string()));
assert_eq!(
cmd.options.get("focus"),
Some(&serde_json::json!("security"))
);
assert_eq!(cmd.metadata.retries, Some(3));
assert_eq!(cmd.metadata.timeout, Some(300));
}
#[test]
fn test_command_from_string() {
let cmd1 = Command::from_string("prodigy-code-review");
assert_eq!(cmd1.name, "prodigy-code-review");
assert!(cmd1.args.is_empty());
let cmd2 = Command::from_string("/prodigy-lint");
assert_eq!(cmd2.name, "prodigy-lint");
let cmd3 = Command::from_string("prodigy-implement-spec iteration-123");
assert_eq!(cmd3.name, "prodigy-implement-spec");
assert_eq!(cmd3.args.len(), 1);
assert_eq!(
cmd3.args[0],
CommandArg::Literal("iteration-123".to_string())
);
let cmd4 = Command::from_string("prodigy-code-review --focus security");
assert_eq!(cmd4.name, "prodigy-code-review");
assert_eq!(
cmd4.options.get("focus"),
Some(&serde_json::json!("security"))
);
}
#[test]
fn test_workflow_command_conversion() {
let simple = WorkflowCommand::Simple("prodigy-code-review".to_string());
let cmd = simple.to_command();
assert_eq!(cmd.name, "prodigy-code-review");
let simple_obj = WorkflowCommand::SimpleObject(SimpleCommand {
name: "prodigy-code-review".to_string(),
commit_required: None,
args: None,
analysis: None,
});
let cmd = simple_obj.to_command();
assert_eq!(cmd.name, "prodigy-code-review");
let structured = WorkflowCommand::Structured(Box::new(Command::new("prodigy-lint")));
let cmd = structured.to_command();
assert_eq!(cmd.name, "prodigy-lint");
}
#[test]
fn test_command_serialization() {
let cmd = Command::new("prodigy-code-review")
.with_option("focus", serde_json::json!("performance"));
let json = serde_json::to_string(&cmd).unwrap();
let deserialized: Command = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, cmd.name);
assert_eq!(deserialized.options, cmd.options);
}
#[test]
fn test_commit_required_field() {
let cmd = Command::new("prodigy-implement-spec");
assert!(!cmd.metadata.commit_required);
let simple_obj = WorkflowCommand::SimpleObject(SimpleCommand {
name: "prodigy-lint".to_string(),
commit_required: Some(false),
args: None,
analysis: None,
});
let cmd = simple_obj.to_command();
assert_eq!(cmd.name, "prodigy-lint");
assert!(!cmd.metadata.commit_required);
let simple_obj = WorkflowCommand::SimpleObject(SimpleCommand {
name: "prodigy-fix".to_string(),
commit_required: Some(true),
args: None,
analysis: None,
});
let cmd = simple_obj.to_command();
assert_eq!(cmd.name, "prodigy-fix");
assert!(cmd.metadata.commit_required);
let simple_obj = WorkflowCommand::SimpleObject(SimpleCommand {
name: "prodigy-refactor".to_string(),
commit_required: None,
args: None,
analysis: None,
});
let cmd = simple_obj.to_command();
assert_eq!(cmd.name, "prodigy-refactor");
assert!(!cmd.metadata.commit_required);
}
#[test]
fn test_commit_required_serialization() {
let simple_cmd = SimpleCommand {
name: "prodigy-lint".to_string(),
commit_required: Some(false),
args: None,
analysis: None,
};
let json = serde_json::to_string(&simple_cmd).unwrap();
assert!(json.contains("\"commit_required\":false"));
let deserialized: SimpleCommand = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.name, "prodigy-lint");
assert_eq!(deserialized.commit_required, Some(false));
let simple_cmd_none = SimpleCommand {
name: "prodigy-test".to_string(),
commit_required: None,
args: None,
analysis: None,
};
let json_none = serde_json::to_string(&simple_cmd_none).unwrap();
assert!(!json_none.contains("commit_required"));
}
#[test]
fn test_analysis_config_defaults() {
let analysis_config = AnalysisConfig {
force_refresh: false,
max_cache_age: 300,
};
assert!(!analysis_config.force_refresh);
assert_eq!(analysis_config.max_cache_age, 300);
}
#[test]
fn test_analysis_config_serialization() {
let analysis_config = AnalysisConfig {
force_refresh: true,
max_cache_age: 600,
};
let json = serde_json::to_string(&analysis_config).unwrap();
let deserialized: AnalysisConfig = serde_json::from_str(&json).unwrap();
assert!(deserialized.force_refresh);
assert_eq!(deserialized.max_cache_age, 600);
}
#[test]
fn test_command_with_analysis_config() {
let mut cmd = Command::new("prodigy-code-review");
cmd.metadata.analysis = Some(AnalysisConfig {
force_refresh: false,
max_cache_age: 300,
});
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"analysis\""));
assert!(json.contains("\"max_cache_age\":300"));
let deserialized: Command = serde_json::from_str(&json).unwrap();
assert!(deserialized.metadata.analysis.is_some());
let analysis = deserialized.metadata.analysis.unwrap();
assert_eq!(analysis.max_cache_age, 300);
}
#[test]
fn test_default_cache_duration() {
assert_eq!(default_cache_duration(), 300);
}
#[test]
fn test_analysis_config_with_defaults() {
let json = r#"{
"force_refresh": true
}"#;
let deserialized: AnalysisConfig = serde_json::from_str(json).unwrap();
assert!(deserialized.force_refresh);
assert_eq!(deserialized.max_cache_age, 300); }
#[test]
fn test_workflow_step_command_parsing() {
let yaml = r#"
claude: "/prodigy-coverage"
id: coverage
commit_required: false
outputs:
spec:
file_pattern: "*-coverage-improvements.md"
analysis:
max_cache_age: 300
"#;
let step: WorkflowStepCommand = serde_yaml::from_str(yaml).unwrap();
assert_eq!(step.claude, Some("/prodigy-coverage".to_string()));
assert_eq!(step.id, Some("coverage".to_string()));
assert!(!step.commit_required);
assert!(step.outputs.is_some());
assert!(step.analysis.is_some());
assert!(step.when.is_none());
}
#[test]
fn test_workflow_step_command_with_when_clause() {
let yaml = r#"
claude: "/prodigy-test"
when: "${build.success} == true"
"#;
let step: WorkflowStepCommand = serde_yaml::from_str(yaml).unwrap();
assert_eq!(step.claude, Some("/prodigy-test".to_string()));
assert_eq!(step.when, Some("${build.success} == true".to_string()));
}
#[test]
fn test_conditional_workflow_serialization() {
let step = WorkflowStepCommand {
claude: Some("/prodigy-test".to_string()),
shell: None,
analyze: None,
test: None,
foreach: None,
write_file: None,
id: Some("test-step".to_string()),
commit_required: false,
analysis: None,
outputs: None,
capture_output: None,
on_failure: None,
on_success: None,
validate: None,
timeout: None,
when: Some("${env} == 'production'".to_string()),
capture_format: None,
capture_streams: None,
output_file: None,
};
let yaml = serde_yaml::to_string(&step).unwrap();
assert!(yaml.contains("when:"));
assert!(yaml.contains("${env} == 'production'"));
let deserialized: WorkflowStepCommand = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(
deserialized.when,
Some("${env} == 'production'".to_string())
);
}
#[test]
fn test_workflow_command_with_workflow_step() {
let yaml = r#"
- claude: "/prodigy-coverage"
id: coverage
commit_required: false
"#;
let commands: Vec<WorkflowCommand> = serde_yaml::from_str(yaml).unwrap();
assert_eq!(commands.len(), 1);
match &commands[0] {
WorkflowCommand::WorkflowStep(step) => {
assert_eq!(step.claude, Some("/prodigy-coverage".to_string()));
assert_eq!(step.id, Some("coverage".to_string()));
assert!(!step.commit_required);
}
_ => panic!("Expected WorkflowStep variant"),
}
}
#[test]
fn test_untagged_enum_debug() {
let yaml_simple = r#"prodigy-code-review"#;
let cmd_simple: WorkflowCommand = serde_yaml::from_str(yaml_simple).unwrap();
assert!(matches!(cmd_simple, WorkflowCommand::Simple(_)));
let yaml_new = r#"
claude: "/prodigy-coverage"
id: coverage
"#;
match serde_yaml::from_str::<WorkflowCommand>(yaml_new) {
Ok(cmd) => {
assert!(matches!(cmd, WorkflowCommand::WorkflowStep(_)));
}
Err(e) => panic!("Failed to parse new format: {e}"),
}
let yaml_simple_obj = r#"
name: prodigy-code-review
commit_required: false
"#;
let cmd_simple_obj: WorkflowCommand = serde_yaml::from_str(yaml_simple_obj).unwrap();
assert!(matches!(cmd_simple_obj, WorkflowCommand::Structured(_)));
}
#[test]
fn test_workflow_config_with_new_syntax() {
let config = parse_test_workflow_config();
assert_eq!(config.commands.len(), 3);
verify_coverage_command(&config.commands[0]);
verify_implement_spec_command(&config.commands[1]);
verify_lint_command(&config.commands[2]);
}
fn parse_test_workflow_config() -> WorkflowConfig {
let yaml = r#"
commands:
- claude: "/prodigy-coverage"
id: coverage
commit_required: false
outputs:
spec:
file_pattern: "*-coverage-improvements.md"
analysis:
max_cache_age: 300
- claude: "/prodigy-implement-spec ${coverage.spec}"
- claude: "/prodigy-lint"
commit_required: false
"#;
serde_yaml::from_str(yaml).unwrap_or_else(|e| {
debug_workflow_parsing_error(yaml, &e);
panic!("Failed to parse WorkflowConfig: {e}");
})
}
fn debug_workflow_parsing_error(yaml: &str, _error: &serde_yaml::Error) {
let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
if let Some(commands) = yaml_value.get("commands") {
println!("Commands value: {commands:?}");
if let Some(seq) = commands.as_sequence() {
debug_command_sequence(seq);
}
}
}
fn debug_command_sequence(seq: &[serde_yaml::Value]) {
for (i, cmd) in seq.iter().enumerate() {
println!("\nCommand {i}: {cmd:?}");
try_parse_as::<WorkflowStepCommand>(cmd, "WorkflowStepCommand");
try_parse_as::<WorkflowCommand>(cmd, "WorkflowCommand");
}
}
fn try_parse_as<T: serde::de::DeserializeOwned + std::fmt::Debug>(
value: &serde_yaml::Value,
type_name: &str,
) {
match serde_yaml::from_value::<T>(value.clone()) {
Ok(parsed) => println!(" Parsed as {type_name}: {parsed:?}"),
Err(e) => println!(" Failed as {type_name}: {e}"),
}
}
fn verify_coverage_command(command: &WorkflowCommand) {
match command {
WorkflowCommand::WorkflowStep(step) => {
assert_eq!(step.claude, Some("/prodigy-coverage".to_string()));
assert_eq!(step.id, Some("coverage".to_string()));
assert!(!step.commit_required);
assert!(step.outputs.is_some());
assert!(step.analysis.is_some());
}
_ => panic!("Expected WorkflowStep variant for coverage command"),
}
}
fn verify_implement_spec_command(command: &WorkflowCommand) {
match command {
WorkflowCommand::WorkflowStep(step) => {
assert_eq!(
step.claude,
Some("/prodigy-implement-spec ${coverage.spec}".to_string())
);
}
_ => panic!("Expected WorkflowStep variant for implement-spec command"),
}
}
fn verify_lint_command(command: &WorkflowCommand) {
match command {
WorkflowCommand::WorkflowStep(step) => {
assert_eq!(step.claude, Some("/prodigy-lint".to_string()));
assert!(!step.commit_required);
}
_ => panic!("Expected WorkflowStep variant for lint command"),
}
}
}