1use serde::{Deserialize, Serialize};
2
3use super::{GenerateItemsAction, SpawnTaskAction, SpawnTasksAction, WorkflowStepConfig};
4
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
7#[serde(rename_all = "snake_case")]
8pub enum StepScope {
9 Task,
11 #[default]
13 Item,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
20pub struct StepBehavior {
21 #[serde(default)]
23 pub on_failure: OnFailureAction,
24 #[serde(default)]
26 pub on_success: OnSuccessAction,
27 #[serde(default)]
29 pub captures: Vec<CaptureDecl>,
30 #[serde(default)]
32 pub post_actions: Vec<PostAction>,
33 #[serde(default)]
35 pub execution: ExecutionMode,
36 #[serde(default)]
38 pub collect_artifacts: bool,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
43#[serde(tag = "action", rename_all = "snake_case")]
44pub enum OnFailureAction {
45 #[default]
47 Continue,
48 SetStatus {
50 status: String,
52 },
53 EarlyReturn {
55 status: String,
57 },
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
62#[serde(tag = "action", rename_all = "snake_case")]
63pub enum OnSuccessAction {
64 #[default]
66 Continue,
67 SetStatus {
69 status: String,
71 },
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
76pub struct CaptureDecl {
77 pub var: String,
79 pub source: CaptureSource,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub json_path: Option<String>,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88#[serde(rename_all = "snake_case")]
89pub enum CaptureSource {
90 Stdout,
92 Stderr,
94 ExitCode,
96 FailedFlag,
98 SuccessFlag,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
104#[serde(rename_all = "snake_case", tag = "type")]
105pub enum PostAction {
106 CreateTicket,
108 ScanTickets,
110 SpawnTask(SpawnTaskAction),
112 SpawnTasks(SpawnTasksAction),
114 GenerateItems(GenerateItemsAction),
116 StorePut {
118 store: String,
120 key: String,
122 from_var: String,
124 },
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
129#[serde(rename_all = "snake_case", tag = "type")]
130pub enum ExecutionMode {
131 #[default]
133 Agent,
134 Builtin {
136 name: String,
138 },
139 Chain,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub enum StepSemanticKind {
146 Builtin {
148 name: String,
150 },
151 Agent {
153 capability: String,
155 },
156 Command,
158 Chain,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
164#[serde(rename_all = "snake_case")]
165pub enum CostPreference {
166 Performance,
168 Quality,
170 #[default]
171 Balance,
173}
174
175const KNOWN_BUILTIN_STEP_NAMES: &[&str] = &[
179 "init_once",
180 "loop_guard",
181 "ticket_scan",
182 "self_test",
183 "self_restart",
184 "item_select",
185];
186
187pub fn validate_step_type(value: &str) -> Result<String, String> {
193 if value.trim().is_empty() {
194 Err("step type cannot be empty".to_string())
195 } else {
196 Ok(value.to_string())
197 }
198}
199
200pub fn is_known_builtin_step_name(value: &str) -> bool {
202 KNOWN_BUILTIN_STEP_NAMES.contains(&value)
203}
204
205pub fn resolve_step_semantic_kind(step: &WorkflowStepConfig) -> Result<StepSemanticKind, String> {
215 if step.builtin.is_some() && step.required_capability.is_some() {
216 return Err(format!(
217 "step '{}' cannot define both builtin and required_capability",
218 step.id
219 ));
220 }
221
222 if !step.chain_steps.is_empty() {
223 return Ok(StepSemanticKind::Chain);
224 }
225
226 if step.command.is_some() {
227 return Ok(StepSemanticKind::Command);
228 }
229
230 if let Some(ref builtin) = step.builtin {
231 if !is_known_builtin_step_name(builtin) {
232 return Err(format!(
233 "step '{}' uses unknown builtin '{}'",
234 step.id, builtin
235 ));
236 }
237 return Ok(StepSemanticKind::Builtin {
238 name: builtin.clone(),
239 });
240 }
241
242 if let Some(ref capability) = step.required_capability {
243 return Ok(StepSemanticKind::Agent {
244 capability: capability.clone(),
245 });
246 }
247
248 if let Some(builtin_name) = super::CONVENTIONS.builtin_name(&step.id) {
250 return Ok(StepSemanticKind::Builtin { name: builtin_name });
251 }
252
253 Ok(StepSemanticKind::Agent {
256 capability: step.id.clone(),
257 })
258}
259
260pub fn normalize_step_execution_mode(step: &mut WorkflowStepConfig) -> Result<(), String> {
262 match resolve_step_semantic_kind(step)? {
263 StepSemanticKind::Builtin { name } => {
264 step.builtin = Some(name.clone());
265 step.required_capability = None;
266 step.behavior.execution = ExecutionMode::Builtin { name };
267 }
268 StepSemanticKind::Agent { capability } => {
269 step.required_capability = Some(capability);
270 step.behavior.execution = ExecutionMode::Agent;
271 }
272 StepSemanticKind::Command => {
273 step.behavior.execution = ExecutionMode::Builtin {
274 name: step.id.clone(),
275 };
276 }
277 StepSemanticKind::Chain => {
278 step.behavior.execution = ExecutionMode::Chain;
279 }
280 }
281 Ok(())
282}
283
284pub fn default_scope_for_step_id(step_id: &str) -> StepScope {
287 super::CONVENTIONS.default_scope(step_id)
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293
294 #[test]
295 fn capture_decl_deserializes_without_json_path() {
296 let capture: CaptureDecl = serde_yaml::from_str(
297 r#"
298var: score
299source: stdout
300"#,
301 )
302 .expect("capture should deserialize");
303
304 assert_eq!(capture.var, "score");
305 assert_eq!(capture.source, CaptureSource::Stdout);
306 assert_eq!(capture.json_path, None);
307 }
308
309 #[test]
310 fn capture_decl_deserializes_with_json_path() {
311 let capture: CaptureDecl = serde_yaml::from_str(
312 r#"
313var: score
314source: stdout
315json_path: $.total_score
316"#,
317 )
318 .expect("capture should deserialize");
319
320 assert_eq!(capture.var, "score");
321 assert_eq!(capture.source, CaptureSource::Stdout);
322 assert_eq!(capture.json_path.as_deref(), Some("$.total_score"));
323 }
324
325 #[test]
326 fn test_validate_step_type_known_ids() {
327 for id in &[
328 "init_once",
329 "plan",
330 "qa",
331 "ticket_scan",
332 "fix",
333 "retest",
334 "loop_guard",
335 "build",
336 "test",
337 "lint",
338 "implement",
339 "review",
340 "git_ops",
341 "qa_doc_gen",
342 "qa_testing",
343 "ticket_fix",
344 "doc_governance",
345 "align_tests",
346 "self_test",
347 "self_restart",
348 "smoke_chain",
349 "evaluate",
350 "item_select",
351 ] {
352 assert!(validate_step_type(id).is_ok(), "expected valid for {}", id);
353 }
354 }
355
356 #[test]
357 fn test_validate_step_type_accepts_custom_ids() {
358 let result = validate_step_type("my_custom_step");
360 assert!(result.is_ok(), "custom step IDs should be accepted");
361 assert_eq!(result.unwrap(), "my_custom_step");
362 }
363
364 #[test]
365 fn test_validate_step_type_rejects_empty() {
366 assert!(validate_step_type("").is_err());
367 assert!(validate_step_type(" ").is_err());
368 }
369
370 #[test]
371 fn test_default_scope_task_steps() {
372 let task_scoped = vec![
373 "plan",
374 "qa_doc_gen",
375 "implement",
376 "self_test",
377 "align_tests",
378 "doc_governance",
379 "review",
380 "build",
381 "test",
382 "lint",
383 "git_ops",
384 "smoke_chain",
385 "loop_guard",
386 "init_once",
387 ];
388 for id in task_scoped {
389 assert_eq!(
390 default_scope_for_step_id(id),
391 StepScope::Task,
392 "expected Task for {}",
393 id
394 );
395 }
396 }
397
398 #[test]
399 fn test_default_scope_item_steps() {
400 let item_scoped = vec![
401 "qa",
402 "qa_testing",
403 "ticket_fix",
404 "ticket_scan",
405 "fix",
406 "retest",
407 ];
408 for id in item_scoped {
409 assert_eq!(
410 default_scope_for_step_id(id),
411 StepScope::Item,
412 "expected Item for {}",
413 id
414 );
415 }
416 }
417
418 #[test]
419 fn test_unknown_step_scope_defaults_to_task() {
420 assert_eq!(default_scope_for_step_id("my_custom_step"), StepScope::Task);
421 }
422
423 #[test]
424 fn test_step_scope_default() {
425 let scope = StepScope::default();
426 assert_eq!(scope, StepScope::Item);
427 }
428
429 #[test]
430 fn test_cost_preference_default() {
431 let pref = CostPreference::default();
432 assert_eq!(pref, CostPreference::Balance);
433 }
434
435 #[test]
436 fn test_cost_preference_serde_round_trip() {
437 for pref_str in &["\"performance\"", "\"quality\"", "\"balance\""] {
438 let pref: CostPreference =
439 serde_json::from_str(pref_str).expect("deserialize cost preference");
440 let json = serde_json::to_string(&pref).expect("serialize cost preference");
441 assert_eq!(&json, pref_str);
442 }
443 }
444
445 #[test]
446 fn test_step_scope_serde_round_trip() {
447 for scope_str in &["\"task\"", "\"item\""] {
448 let scope: StepScope = serde_json::from_str(scope_str).expect("deserialize step scope");
449 let json = serde_json::to_string(&scope).expect("serialize step scope");
450 assert_eq!(&json, scope_str);
451 }
452 }
453
454 #[test]
455 fn test_post_action_store_put_serde_round_trip() {
456 let action = PostAction::StorePut {
457 store: "metrics".to_string(),
458 key: "bench_result".to_string(),
459 from_var: "qa_score".to_string(),
460 };
461 let json = serde_json::to_string(&action).expect("serialize StorePut");
462 assert!(json.contains("\"type\":\"store_put\""));
463 assert!(json.contains("\"store\":\"metrics\""));
464 assert!(json.contains("\"key\":\"bench_result\""));
465 assert!(json.contains("\"from_var\":\"qa_score\""));
466
467 let deserialized: PostAction = serde_json::from_str(&json).expect("deserialize StorePut");
468 match deserialized {
469 PostAction::StorePut {
470 store,
471 key,
472 from_var,
473 } => {
474 assert_eq!(store, "metrics");
475 assert_eq!(key, "bench_result");
476 assert_eq!(from_var, "qa_score");
477 }
478 _ => panic!("expected StorePut variant"),
479 }
480 }
481}