use crate::{DrivenConfig, DrivenError, Result};
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Default)]
pub struct ConfigValidator {
errors: Vec<ValidationError>,
warnings: Vec<ValidationWarning>,
}
#[derive(Debug, Clone)]
pub struct ValidationError {
pub field: String,
pub message: String,
pub suggestion: String,
}
#[derive(Debug, Clone)]
pub struct ValidationWarning {
pub field: String,
pub message: String,
pub suggestion: String,
}
#[derive(Debug)]
pub struct ValidationReport {
pub valid: bool,
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
}
impl ValidationReport {
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
pub fn format_errors(&self) -> String {
self.errors
.iter()
.map(|e| {
format!(
" - {}: {}\n Suggestion: {}",
e.field, e.message, e.suggestion
)
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn format_warnings(&self) -> String {
self.warnings
.iter()
.map(|w| {
format!(
" - {}: {}\n Suggestion: {}",
w.field, w.message, w.suggestion
)
})
.collect::<Vec<_>>()
.join("\n")
}
}
impl ConfigValidator {
pub fn new() -> Self {
Self::default()
}
pub fn validate(&mut self, config: &DrivenConfig) -> ValidationReport {
self.errors.clear();
self.warnings.clear();
self.validate_version(&config.version);
self.validate_editors(&config.editors);
self.validate_sync(&config.sync);
self.validate_templates(&config.templates);
self.validate_context(&config.context);
ValidationReport {
valid: self.errors.is_empty(),
errors: self.errors.clone(),
warnings: self.warnings.clone(),
}
}
fn validate_version(&mut self, version: &str) {
if version.is_empty() {
self.errors.push(ValidationError {
field: "version".to_string(),
message: "Version cannot be empty".to_string(),
suggestion: "Set version to '1.0'".to_string(),
});
} else if !version.chars().all(|c| c.is_ascii_digit() || c == '.') {
self.warnings.push(ValidationWarning {
field: "version".to_string(),
message: format!("Unusual version format: '{}'", version),
suggestion: "Use semantic versioning (e.g., '1.0', '2.1')".to_string(),
});
}
}
fn validate_editors(&mut self, editors: &crate::EditorConfig) {
let enabled_count = [
editors.cursor,
editors.copilot,
editors.windsurf,
editors.claude,
editors.aider,
editors.cline,
]
.iter()
.filter(|&&e| e)
.count();
if enabled_count == 0 {
self.warnings.push(ValidationWarning {
field: "editors".to_string(),
message: "No editors are enabled".to_string(),
suggestion: "Enable at least one editor (e.g., editors.cursor = true)".to_string(),
});
}
}
fn validate_sync(&mut self, sync: &crate::SyncConfig) {
if sync.source_of_truth.is_empty() {
self.errors.push(ValidationError {
field: "sync.source_of_truth".to_string(),
message: "Source of truth path cannot be empty".to_string(),
suggestion: "Set to '.driven/rules.drv' or another valid path".to_string(),
});
} else if !sync.source_of_truth.ends_with(".drv") && !sync.source_of_truth.ends_with(".md")
{
self.warnings.push(ValidationWarning {
field: "sync.source_of_truth".to_string(),
message: format!("Unusual file extension: '{}'", sync.source_of_truth),
suggestion: "Use .drv for binary format or .md for markdown".to_string(),
});
}
}
fn validate_templates(&mut self, templates: &crate::TemplateConfig) {
let mut seen = HashSet::new();
for persona in &templates.personas {
if !seen.insert(persona) {
self.warnings.push(ValidationWarning {
field: "templates.personas".to_string(),
message: format!("Duplicate persona: '{}'", persona),
suggestion: "Remove duplicate entries".to_string(),
});
}
}
seen.clear();
for standard in &templates.standards {
if !seen.insert(standard) {
self.warnings.push(ValidationWarning {
field: "templates.standards".to_string(),
message: format!("Duplicate standard: '{}'", standard),
suggestion: "Remove duplicate entries".to_string(),
});
}
}
}
fn validate_context(&mut self, context: &crate::ContextConfig) {
if context.include.is_empty() {
self.warnings.push(ValidationWarning {
field: "context.include".to_string(),
message: "No include patterns specified".to_string(),
suggestion: "Add patterns like 'src/**' to include source files".to_string(),
});
}
for include in &context.include {
for exclude in &context.exclude {
if include == exclude {
self.errors.push(ValidationError {
field: "context".to_string(),
message: format!("Pattern '{}' is both included and excluded", include),
suggestion: "Remove the pattern from either include or exclude".to_string(),
});
}
}
}
if context.index_path.is_empty() {
self.errors.push(ValidationError {
field: "context.index_path".to_string(),
message: "Index path cannot be empty".to_string(),
suggestion: "Set to '.driven/index.drv'".to_string(),
});
}
}
pub fn validate_path_exists(&mut self, field: &str, path: &Path) {
if !path.exists() {
self.errors.push(ValidationError {
field: field.to_string(),
message: format!("Path does not exist: {}", path.display()),
suggestion: "Create the file or update the path".to_string(),
});
}
}
pub fn add_error(&mut self, field: &str, message: &str, suggestion: &str) {
self.errors.push(ValidationError {
field: field.to_string(),
message: message.to_string(),
suggestion: suggestion.to_string(),
});
}
pub fn add_warning(&mut self, field: &str, message: &str, suggestion: &str) {
self.warnings.push(ValidationWarning {
field: field.to_string(),
message: message.to_string(),
suggestion: suggestion.to_string(),
});
}
}
pub fn validate_config(config: &DrivenConfig) -> Result<ValidationReport> {
let mut validator = ConfigValidator::new();
let report = validator.validate(config);
if report.has_errors() {
Err(DrivenError::Validation(report.format_errors()))
} else {
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::EditorConfig;
#[test]
fn test_valid_config() {
let config = DrivenConfig::default();
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
assert!(report.valid);
assert!(!report.has_errors());
}
#[test]
fn test_empty_version() {
let mut config = DrivenConfig::default();
config.version = String::new();
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
assert!(!report.valid);
assert!(report.errors.iter().any(|e| e.field == "version"));
}
#[test]
fn test_no_editors_enabled() {
let mut config = DrivenConfig::default();
config.editors = EditorConfig {
cursor: false,
copilot: false,
windsurf: false,
claude: false,
aider: false,
cline: false,
};
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
assert!(report.has_warnings());
assert!(report.warnings.iter().any(|w| w.field == "editors"));
}
#[test]
fn test_empty_source_of_truth() {
let mut config = DrivenConfig::default();
config.sync.source_of_truth = String::new();
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|e| e.field == "sync.source_of_truth")
);
}
#[test]
fn test_duplicate_personas() {
let mut config = DrivenConfig::default();
config.templates.personas = vec!["architect".to_string(), "architect".to_string()];
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
assert!(report.has_warnings());
assert!(
report
.warnings
.iter()
.any(|w| w.message.contains("Duplicate"))
);
}
#[test]
fn test_overlapping_patterns() {
let mut config = DrivenConfig::default();
config.context.include = vec!["src/**".to_string()];
config.context.exclude = vec!["src/**".to_string()];
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
assert!(!report.valid);
assert!(
report
.errors
.iter()
.any(|e| e.message.contains("both included and excluded"))
);
}
#[test]
fn test_validation_report_formatting() {
let report = ValidationReport {
valid: false,
errors: vec![ValidationError {
field: "test.field".to_string(),
message: "Test error".to_string(),
suggestion: "Fix it".to_string(),
}],
warnings: vec![ValidationWarning {
field: "test.warning".to_string(),
message: "Test warning".to_string(),
suggestion: "Consider fixing".to_string(),
}],
};
let errors = report.format_errors();
assert!(errors.contains("test.field"));
assert!(errors.contains("Test error"));
let warnings = report.format_warnings();
assert!(warnings.contains("test.warning"));
assert!(warnings.contains("Test warning"));
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn prop_default_config_valid(_seed in any::<u64>()) {
let config = DrivenConfig::default();
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
prop_assert!(report.valid, "Default config should be valid");
}
#[test]
fn prop_empty_version_invalid(_seed in any::<u64>()) {
let mut config = DrivenConfig::default();
config.version = String::new();
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
prop_assert!(!report.valid);
prop_assert!(report.errors.iter().any(|e| e.field == "version"));
}
#[test]
fn prop_empty_source_of_truth_invalid(_seed in any::<u64>()) {
let mut config = DrivenConfig::default();
config.sync.source_of_truth = String::new();
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
prop_assert!(!report.valid);
prop_assert!(report.errors.iter().any(|e| e.field.contains("source_of_truth")));
}
#[test]
fn prop_errors_have_suggestions(
version in ".*",
source in ".*",
) {
let mut config = DrivenConfig::default();
config.version = version;
config.sync.source_of_truth = source;
let mut validator = ConfigValidator::new();
let report = validator.validate(&config);
for error in &report.errors {
prop_assert!(!error.suggestion.is_empty(),
"Error for '{}' should have a suggestion", error.field);
}
for warning in &report.warnings {
prop_assert!(!warning.suggestion.is_empty(),
"Warning for '{}' should have a suggestion", warning.field);
}
}
#[test]
fn prop_validation_deterministic(
version in "[0-9.]+",
) {
let mut config = DrivenConfig::default();
config.version = version;
let mut validator1 = ConfigValidator::new();
let report1 = validator1.validate(&config);
let mut validator2 = ConfigValidator::new();
let report2 = validator2.validate(&config);
prop_assert_eq!(report1.valid, report2.valid);
prop_assert_eq!(report1.errors.len(), report2.errors.len());
prop_assert_eq!(report1.warnings.len(), report2.warnings.len());
}
}
}