use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use anyhow::{Context, Result, bail};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RitualDefinition {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub extends: Option<String>,
pub phases: Vec<PhaseDefinition>,
#[serde(default)]
pub config: RitualConfig,
#[serde(default)]
pub task_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhaseDefinition {
pub id: String,
#[serde(flatten)]
pub kind: PhaseKind,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub approval: ApprovalRequirement,
#[serde(default)]
pub skip_if: Option<SkipCondition>,
#[serde(default)]
pub timeout_minutes: Option<u32>,
#[serde(default)]
pub input: Vec<ArtifactRef>,
#[serde(default)]
pub output: Vec<ArtifactSpec>,
#[serde(default)]
pub hooks: PhaseHooks,
#[serde(default)]
pub on_failure: FailureStrategy,
#[serde(default)]
pub harness_config: Option<HarnessConfigOverride>,
}
impl Default for PhaseDefinition {
fn default() -> Self {
Self {
id: String::new(),
kind: PhaseKind::Shell {
command: "echo 'no-op'".to_string(),
},
model: None,
approval: ApprovalRequirement::default(),
skip_if: None,
timeout_minutes: None,
input: Vec::new(),
output: Vec::new(),
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::default(),
harness_config: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PhaseKind {
Skill {
#[serde(alias = "skill")]
name: String,
},
GidCommand {
command: String,
#[serde(default)]
args: Vec<String>,
},
Harness {
#[serde(default)]
config_overrides: Option<HarnessConfigOverride>,
},
Shell {
command: String,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HarnessConfigOverride {
#[serde(default)]
pub max_concurrent: Option<usize>,
#[serde(default)]
pub max_retries: Option<u32>,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalRequirement {
Required,
Optional,
#[default]
Auto,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SkipCondition {
FileExists { file_exists: String },
GlobMatches { glob_matches: String },
ArtifactExists { artifact_exists: String },
Always { always: bool },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ArtifactRef {
#[serde(default)]
pub from_phase: Option<String>,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactSpec {
pub path: String,
#[serde(default = "default_required")]
pub required: bool,
}
fn default_required() -> bool {
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PhaseHooks {
#[serde(default)]
pub pre: Vec<String>,
#[serde(default)]
pub post: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum FailureStrategy {
Retry {
#[serde(default = "default_max_attempts")]
max_attempts: u32,
},
#[default]
Escalate,
Skip,
Abort,
}
fn default_max_attempts() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RitualConfig {
#[serde(default = "default_model")]
pub default_model: String,
#[serde(default)]
pub default_approval: ApprovalRequirement,
#[serde(default = "default_state_file")]
pub state_file: String,
#[serde(default = "default_log_file")]
pub log_file: String,
#[serde(default)]
pub notify: Option<super::notifier::RitualNotifyConfig>,
}
impl Default for RitualConfig {
fn default() -> Self {
Self {
default_model: default_model(),
default_approval: ApprovalRequirement::default(),
state_file: default_state_file(),
log_file: default_log_file(),
notify: None,
}
}
}
fn default_model() -> String {
"sonnet".to_string()
}
fn default_state_file() -> String {
".gid/ritual-state.json".to_string()
}
fn default_log_file() -> String {
".gid/execution-log.jsonl".to_string()
}
impl RitualDefinition {
pub fn load(path: &Path, template_dirs: &[PathBuf]) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read ritual file: {}", path.display()))?;
let mut definition: RitualDefinition = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse ritual YAML: {}", path.display()))?;
if let Some(ref template_name) = definition.extends {
let template = Self::load_template(template_name, template_dirs)?;
definition = Self::merge_with_template(definition, template);
}
definition.validate()?;
Ok(definition)
}
fn load_template(name: &str, template_dirs: &[PathBuf]) -> Result<RitualDefinition> {
for dir in template_dirs {
let path = dir.join(format!("{}.yml", name));
if path.exists() {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read template: {}", path.display()))?;
let template: RitualDefinition = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse template: {}", path.display()))?;
return Ok(template);
}
let path = dir.join(format!("{}.yaml", name));
if path.exists() {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read template: {}", path.display()))?;
let template: RitualDefinition = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse template: {}", path.display()))?;
return Ok(template);
}
}
bail!("Template not found: {}", name)
}
fn merge_with_template(mut definition: RitualDefinition, template: RitualDefinition) -> RitualDefinition {
if definition.phases.is_empty() {
definition.phases = template.phases;
}
if definition.description.is_none() {
definition.description = template.description;
}
definition
}
pub fn validate(&self) -> Result<()> {
let mut seen_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for phase in &self.phases {
if !seen_ids.insert(&phase.id) {
bail!("Duplicate phase ID: {}", phase.id);
}
}
for phase in &self.phases {
for input in &phase.input {
if let Some(ref from_phase) = input.from_phase {
if !seen_ids.contains(from_phase.as_str()) {
bail!(
"Phase '{}' references unknown phase '{}' in input artifact",
phase.id, from_phase
);
}
}
}
}
Ok(())
}
pub fn get_phase(&self, id: &str) -> Option<&PhaseDefinition> {
self.phases.iter().find(|p| p.id == id)
}
pub fn phase_index(&self, id: &str) -> Option<usize> {
self.phases.iter().position(|p| p.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_minimal_ritual() {
let yaml = r#"
name: minimal
phases:
- id: test
kind: shell
command: echo hello
"#;
let def: RitualDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(def.name, "minimal");
assert_eq!(def.phases.len(), 1);
assert_eq!(def.phases[0].id, "test");
}
#[test]
fn test_parse_full_ritual() {
let yaml = r#"
name: full-dev-cycle
description: Complete development cycle
phases:
- id: requirements
kind: skill
name: requirements
model: sonnet
approval: required
output:
- path: ".gid/features/{feature}/requirements.md"
required: true
- id: design
kind: skill
name: design-doc
approval: required
input:
- from_phase: requirements
path: ".gid/features/{feature}/requirements.md"
output:
- path: ".gid/features/{feature}/design.md"
- id: execute
kind: harness
approval: auto
harness_config:
max_concurrent: 3
hooks:
post: ["gid extract"]
on_failure:
type: escalate
config:
default_model: sonnet
default_approval: optional
"#;
let def: RitualDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(def.name, "full-dev-cycle");
assert_eq!(def.phases.len(), 3);
assert_eq!(def.phases[0].id, "requirements");
assert!(matches!(def.phases[0].kind, PhaseKind::Skill { ref name } if name == "requirements"));
assert_eq!(def.phases[0].approval, ApprovalRequirement::Required);
assert_eq!(def.phases[1].input.len(), 1);
assert_eq!(def.phases[1].input[0].from_phase, Some("requirements".to_string()));
assert!(matches!(def.phases[2].kind, PhaseKind::Harness { .. }));
assert_eq!(def.phases[2].hooks.post, vec!["gid extract"]);
}
#[test]
fn test_validate_duplicate_ids() {
let def = RitualDefinition {
name: "test".to_string(),
description: None,
extends: None,
phases: vec![
PhaseDefinition {
id: "dup".to_string(),
kind: PhaseKind::Shell { command: "echo 1".to_string() },
model: None,
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: None,
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
PhaseDefinition {
id: "dup".to_string(),
kind: PhaseKind::Shell { command: "echo 2".to_string() },
model: None,
approval: ApprovalRequirement::Auto,
skip_if: None,
timeout_minutes: None,
input: vec![],
output: vec![],
hooks: PhaseHooks::default(),
on_failure: FailureStrategy::Escalate,
harness_config: None,
},
],
config: RitualConfig::default(),
task_context: None,
};
assert!(def.validate().is_err());
}
#[test]
fn test_skip_conditions() {
let yaml = r#"
name: skip-test
phases:
- id: p1
kind: shell
command: echo 1
skip_if:
file_exists: ".gid/done"
- id: p2
kind: shell
command: echo 2
skip_if:
glob_matches: ".gid/features/*/done"
- id: p3
kind: shell
command: echo 3
skip_if:
always: true
"#;
let def: RitualDefinition = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(def.phases[0].skip_if, Some(SkipCondition::FileExists { .. })));
assert!(matches!(def.phases[1].skip_if, Some(SkipCondition::GlobMatches { .. })));
assert!(matches!(def.phases[2].skip_if, Some(SkipCondition::Always { .. })));
}
#[test]
fn test_failure_strategies() {
let yaml = r#"
name: failure-test
phases:
- id: skip
kind: shell
command: echo 2
on_failure:
type: skip
- id: abort
kind: shell
command: echo 3
on_failure:
type: abort
- id: escalate
kind: shell
command: echo 4
on_failure:
type: escalate
"#;
let def: RitualDefinition = serde_yaml::from_str(yaml).unwrap();
assert!(matches!(def.phases[0].on_failure, FailureStrategy::Skip));
assert!(matches!(def.phases[1].on_failure, FailureStrategy::Abort));
assert!(matches!(def.phases[2].on_failure, FailureStrategy::Escalate));
let yaml_retry = r#"
name: retry-test
phases:
- id: retry
kind: shell
command: echo 1
on_failure:
type: retry
max_attempts: 5
"#;
let def2: RitualDefinition = serde_yaml::from_str(yaml_retry).unwrap();
match &def2.phases[0].on_failure {
FailureStrategy::Retry { max_attempts } => assert_eq!(*max_attempts, 5),
other => panic!("Expected Retry, got {:?}", other),
}
}
}