use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ValidationConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commands: Option<Vec<crate::config::WorkflowCommand>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected_schema: Option<serde_json::Value>,
#[serde(default = "default_threshold")]
pub threshold: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_incomplete: Option<OnIncompleteConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result_file: Option<String>,
}
impl<'de> serde::Deserialize<'de> for ValidationConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum ValidationConfigHelper {
Array(Vec<crate::config::WorkflowCommand>),
Object {
#[serde(skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
claude: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
commands: Option<Vec<crate::config::WorkflowCommand>>,
#[serde(skip_serializing_if = "Option::is_none")]
expected_schema: Option<serde_json::Value>,
#[serde(default = "default_threshold")]
threshold: f64,
#[serde(skip_serializing_if = "Option::is_none")]
timeout: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
on_incomplete: Box<Option<OnIncompleteConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
result_file: Option<String>,
},
}
let helper = ValidationConfigHelper::deserialize(deserializer)?;
match helper {
ValidationConfigHelper::Array(cmds) => Ok(ValidationConfig {
command: None,
shell: None,
claude: None,
commands: Some(cmds),
expected_schema: None,
threshold: default_threshold(),
timeout: None,
on_incomplete: None,
result_file: None,
}),
ValidationConfigHelper::Object {
command,
shell,
claude,
commands,
expected_schema,
threshold,
timeout,
on_incomplete,
result_file,
} => Ok(ValidationConfig {
command,
shell,
claude,
commands,
expected_schema,
threshold,
timeout,
on_incomplete: *on_incomplete,
result_file,
}),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct OnIncompleteConfig {
#[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 commands: Option<Vec<crate::config::WorkflowCommand>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(default = "default_max_attempts")]
pub max_attempts: u32,
#[serde(default = "default_fail_workflow")]
pub fail_workflow: bool,
#[serde(default)]
pub commit_required: bool,
}
impl<'de> serde::Deserialize<'de> for OnIncompleteConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OnIncompleteConfigHelper {
Array(Vec<crate::config::WorkflowCommand>),
Object {
#[serde(skip_serializing_if = "Option::is_none")]
claude: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
shell: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
commands: Option<Vec<crate::config::WorkflowCommand>>,
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
#[serde(default = "default_max_attempts")]
max_attempts: u32,
#[serde(default = "default_fail_workflow")]
fail_workflow: bool,
#[serde(default)]
commit_required: bool,
},
}
let helper = OnIncompleteConfigHelper::deserialize(deserializer)?;
match helper {
OnIncompleteConfigHelper::Array(cmds) => Ok(OnIncompleteConfig {
claude: None,
shell: None,
commands: Some(cmds),
prompt: None,
max_attempts: default_max_attempts(),
fail_workflow: default_fail_workflow(),
commit_required: false,
}),
OnIncompleteConfigHelper::Object {
claude,
shell,
commands,
prompt,
max_attempts,
fail_workflow,
commit_required,
} => Ok(OnIncompleteConfig {
claude,
shell,
commands,
prompt,
max_attempts,
fail_workflow,
commit_required,
}),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub completion_percentage: f64,
pub status: ValidationStatus,
#[serde(default)]
pub implemented: Vec<String>,
#[serde(default)]
pub missing: Vec<String>,
#[serde(default)]
pub gaps: HashMap<String, GapDetail>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw_output: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GapDetail {
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
pub severity: Severity,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggested_fix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Critical,
High,
Medium,
Low,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ValidationStatus {
Complete,
Incomplete,
Failed,
Skipped,
}
fn default_threshold() -> f64 {
100.0
}
fn default_max_attempts() -> u32 {
2
}
fn default_fail_workflow() -> bool {
true
}
impl ValidationConfig {
pub fn is_complete(&self, result: &ValidationResult) -> bool {
result.completion_percentage >= self.threshold
}
pub fn validate(&self) -> Result<()> {
let has_shell_cmd = self.shell.is_some() || self.command.is_some();
if !has_shell_cmd && self.claude.is_none() {
return Err(anyhow!(
"Validation requires either shell/command or claude to be specified"
));
}
if has_shell_cmd && self.claude.is_some() {
return Err(anyhow!(
"Cannot specify both shell/command and claude for validation"
));
}
if self.shell.is_some() && self.command.is_some() {
return Err(anyhow!(
"Cannot specify both 'shell' and 'command' (command is deprecated, use shell)"
));
}
if self.threshold < 0.0 || self.threshold > 100.0 {
return Err(anyhow!("Threshold must be between 0 and 100"));
}
if let Some(on_incomplete) = &self.on_incomplete {
on_incomplete.validate()?;
}
Ok(())
}
}
impl OnIncompleteConfig {
pub fn validate(&self) -> Result<()> {
if self.claude.is_none() && self.shell.is_none() && self.prompt.is_none() {
return Err(anyhow!(
"OnIncomplete requires either claude, shell, or prompt to be specified"
));
}
if self.max_attempts == 0 {
return Err(anyhow!("max_attempts must be greater than 0"));
}
Ok(())
}
pub fn has_command(&self) -> bool {
self.claude.is_some() || self.shell.is_some()
}
}
impl ValidationResult {
pub fn complete() -> Self {
Self {
completion_percentage: 100.0,
status: ValidationStatus::Complete,
implemented: Vec::new(),
missing: Vec::new(),
gaps: HashMap::new(),
raw_output: None,
}
}
pub fn incomplete(
percentage: f64,
missing: Vec<String>,
gaps: HashMap<String, GapDetail>,
) -> Self {
Self {
completion_percentage: percentage,
status: ValidationStatus::Incomplete,
implemented: Vec::new(),
missing,
gaps,
raw_output: None,
}
}
pub fn failed(error: String) -> Self {
Self {
completion_percentage: 0.0,
status: ValidationStatus::Failed,
implemented: Vec::new(),
missing: vec![error],
gaps: HashMap::new(),
raw_output: None,
}
}
pub fn from_json(json_str: &str) -> Result<Self> {
serde_json::from_str(json_str)
.map_err(|e| anyhow!("Failed to parse validation result: {}", e))
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self)
.map_err(|e| anyhow!("Failed to serialize validation result: {}", e))
}
pub fn gaps_summary(&self) -> String {
if self.gaps.is_empty() {
return String::new();
}
let gap_list: Vec<String> = self
.gaps
.iter()
.map(|(key, detail)| {
format!(
"{}: {} ({})",
key,
detail.description,
format!("{:?}", detail.severity).to_lowercase()
)
})
.collect();
gap_list.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validation_config_defaults() {
let yaml = r#"
claude: "/prodigy-validate-spec 01"
"#;
let config: ValidationConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.claude, Some("/prodigy-validate-spec 01".to_string()));
assert_eq!(config.threshold, 100.0);
assert!(config.on_incomplete.is_none());
}
#[test]
fn test_validation_config_with_on_incomplete() {
let yaml = r#"
command: "cargo test"
threshold: 90
on_incomplete:
claude: "/prodigy-fix-tests ${validation.gaps}"
max_attempts: 3
fail_workflow: false
commit_required: false
"#;
let config: ValidationConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.command, Some("cargo test".to_string()));
assert_eq!(config.threshold, 90.0);
let on_incomplete = config.on_incomplete.unwrap();
assert_eq!(
on_incomplete.claude,
Some("/prodigy-fix-tests ${validation.gaps}".to_string())
);
assert_eq!(on_incomplete.max_attempts, 3);
assert!(!on_incomplete.fail_workflow);
}
#[test]
fn test_validation_result_serialization() {
let mut gaps = HashMap::new();
gaps.insert(
"auth".to_string(),
GapDetail {
description: "Authentication not implemented".to_string(),
location: Some("src/auth.rs".to_string()),
severity: Severity::Critical,
suggested_fix: Some("Implement JWT validation".to_string()),
},
);
let result = ValidationResult {
completion_percentage: 75.0,
status: ValidationStatus::Incomplete,
implemented: vec!["Database schema".to_string()],
missing: vec!["Authentication".to_string()],
gaps,
raw_output: None,
};
let json = result.to_json().unwrap();
let parsed: ValidationResult = ValidationResult::from_json(&json).unwrap();
assert_eq!(parsed.completion_percentage, 75.0);
assert_eq!(parsed.status, ValidationStatus::Incomplete);
assert_eq!(parsed.implemented.len(), 1);
assert_eq!(parsed.missing.len(), 1);
assert_eq!(parsed.gaps.len(), 1);
}
#[test]
fn test_validation_config_validation() {
let mut config = ValidationConfig {
command: None,
shell: None,
claude: None,
commands: None,
expected_schema: None,
threshold: 100.0,
timeout: None,
on_incomplete: None,
result_file: None,
};
assert!(config.validate().is_err());
config.command = Some("/prodigy-validate".to_string());
assert!(config.validate().is_ok());
config.threshold = 150.0;
assert!(config.validate().is_err());
config.threshold = 95.0;
assert!(config.validate().is_ok());
}
#[test]
fn test_validation_config_shell_field() {
let mut config = ValidationConfig {
command: None,
shell: None,
claude: None,
commands: None,
expected_schema: None,
threshold: 95.0,
timeout: None,
on_incomplete: None,
result_file: None,
};
config.shell = Some("bash -c 'echo test'".to_string());
assert!(config.validate().is_ok());
config.command = Some("echo old".to_string());
assert!(config.validate().is_err());
config.shell = None;
assert!(config.validate().is_ok());
config.shell = Some("echo test".to_string());
config.command = None;
config.claude = Some("/claude-cmd".to_string());
assert!(config.validate().is_err());
}
#[test]
fn test_on_incomplete_validation() {
let mut config = OnIncompleteConfig {
claude: None,
shell: None,
commands: None,
prompt: None,
max_attempts: 2,
fail_workflow: true,
commit_required: false,
};
assert!(config.validate().is_err());
config.claude = Some("/prodigy-fix".to_string());
assert!(config.validate().is_ok());
config.claude = None;
config.shell = Some("echo test".to_string());
assert!(config.validate().is_ok());
config.shell = None;
config.prompt = Some("Continue?".to_string());
assert!(config.validate().is_ok());
config.max_attempts = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_validation_result_helpers() {
let result = ValidationResult::complete();
assert_eq!(result.completion_percentage, 100.0);
assert_eq!(result.status, ValidationStatus::Complete);
let mut gaps = HashMap::new();
gaps.insert(
"tests".to_string(),
GapDetail {
description: "Missing unit tests".to_string(),
location: None,
severity: Severity::High,
suggested_fix: None,
},
);
let incomplete = ValidationResult::incomplete(60.0, vec!["Unit tests".to_string()], gaps);
assert_eq!(incomplete.completion_percentage, 60.0);
assert_eq!(incomplete.status, ValidationStatus::Incomplete);
let failed = ValidationResult::failed("Command not found".to_string());
assert_eq!(failed.completion_percentage, 0.0);
assert_eq!(failed.status, ValidationStatus::Failed);
}
#[test]
fn test_gaps_summary() {
let mut gaps = HashMap::new();
gaps.insert(
"auth".to_string(),
GapDetail {
description: "Missing authentication".to_string(),
location: None,
severity: Severity::Critical,
suggested_fix: None,
},
);
gaps.insert(
"tests".to_string(),
GapDetail {
description: "No test coverage".to_string(),
location: None,
severity: Severity::High,
suggested_fix: None,
},
);
let result = ValidationResult::incomplete(50.0, vec![], gaps);
let summary = result.gaps_summary();
assert!(summary.contains("Missing authentication"));
assert!(summary.contains("No test coverage"));
assert!(summary.contains("critical"));
assert!(summary.contains("high"));
}
#[test]
fn test_validation_config_array_format() {
let yaml = r#"
- shell: "debtmap analyze . --lcov target/coverage/lcov.info --output .prodigy/debtmap-after.json --format json"
- shell: "debtmap compare --before .prodigy/debtmap-before.json --after .prodigy/debtmap-after.json --output .prodigy/comparison.json --format json"
- claude: "/prodigy-validate-debtmap-improvement --comparison .prodigy/comparison.json --output .prodigy/debtmap-validation.json"
result_file: ".prodigy/debtmap-validation.json"
threshold: 75
"#;
let config: ValidationConfig =
serde_yaml::from_str(yaml).expect("Failed to parse validation config array");
assert!(config.commands.is_some());
let commands = config.commands.unwrap();
assert_eq!(commands.len(), 3);
assert_eq!(config.threshold, 100.0);
}
#[test]
fn test_on_incomplete_array_format() {
let yaml = r#"
- claude: "/prodigy-complete-debtmap-fix --gaps ${validation.gaps}"
commit_required: true
- shell: "just coverage-lcov"
- shell: "debtmap analyze . --output .prodigy/debtmap-after.json"
"#;
let config: OnIncompleteConfig =
serde_yaml::from_str(yaml).expect("Failed to parse on_incomplete config array");
assert!(config.commands.is_some());
let commands = config.commands.unwrap();
assert_eq!(commands.len(), 3);
}
#[test]
fn test_nested_validation_with_arrays() {
let yaml = r#"
validate:
- shell: "debtmap analyze . --lcov target/coverage/lcov.info --output .prodigy/debtmap-after.json --format json"
- shell: "debtmap compare --before .prodigy/debtmap-before.json --after .prodigy/debtmap-after.json --output .prodigy/comparison.json --format json"
- claude: "/prodigy-validate-debtmap-improvement --comparison .prodigy/comparison.json --output .prodigy/debtmap-validation.json"
"#;
#[derive(serde::Deserialize)]
struct TestStruct {
validate: ValidationConfig,
}
let result: TestStruct =
serde_yaml::from_str(yaml).expect("Failed to parse nested validation config");
assert!(result.validate.commands.is_some());
let commands = result.validate.commands.unwrap();
assert_eq!(commands.len(), 3);
assert_eq!(result.validate.threshold, 100.0); assert!(result.validate.on_incomplete.is_none()); }
#[test]
fn test_parse_actual_debtmap_workflow() {
let yaml = r#"
- shell: "just coverage-lcov"
- shell: "debtmap analyze . --lcov target/coverage/lcov.info --output .prodigy/debtmap-before.json --format json"
- claude: "/prodigy-debtmap-plan --before .prodigy/debtmap-before.json --output .prodigy/IMPLEMENTATION_PLAN.md"
capture_output: true
validate:
- claude: "/prodigy-validate-debtmap-plan --before .prodigy/debtmap-before.json --plan .prodigy/IMPLEMENTATION_PLAN.md --output .prodigy/plan-validation.json"
result_file: ".prodigy/plan-validation.json"
threshold: 75
on_incomplete:
- claude: "/prodigy-revise-debtmap-plan --gaps ${validation.gaps} --plan .prodigy/IMPLEMENTATION_PLAN.md"
max_attempts: 3
fail_workflow: false
"#;
let result: Result<Vec<crate::config::WorkflowCommand>, _> = serde_yaml::from_str(yaml);
match result {
Ok(cmds) => {
assert_eq!(cmds.len(), 3);
}
Err(e) => {
panic!("Failed to parse workflow: {}", e);
}
}
}
}