use crate::entity::{classify_path, EntityKind}; use crate::frontmatter::split_frontmatter_with_path; use crate::types::*; use std::collections::HashSet; use std::path::Path; #[derive(Debug, Clone)] pub struct ValidationIssue { pub level: IssueLevel, pub field: Option, pub message: String, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "lowercase")] pub enum IssueLevel { Error, Warning, } impl serde::Serialize for ValidationIssue { fn serialize(&self, s: S) -> Result { use serde::ser::SerializeStruct; let mut st = s.serialize_struct("ValidationIssue", 3)?; st.serialize_field("level", &self.level)?; st.serialize_field("field", &self.field)?; st.serialize_field("message", &self.message)?; st.end() } } /// Validate a vault file given its relative path and raw content. pub fn validate(relative_path: &Path, content: &str) -> Vec { let mut issues = Vec::new(); // Check frontmatter exists let (yaml, _body) = match split_frontmatter_with_path(content, relative_path) { Ok(pair) => pair, Err(_) => { issues.push(ValidationIssue { level: IssueLevel::Error, field: None, message: "Missing or malformed frontmatter".into(), }); return issues; } }; let kind = classify_path(relative_path); match kind { EntityKind::Agent => validate_agent(yaml, &mut issues), EntityKind::Skill => validate_skill(yaml, &mut issues), EntityKind::CronActive | EntityKind::CronPaused | EntityKind::CronTemplate => { validate_cron(yaml, &mut issues) } EntityKind::HumanTask(_) => validate_human_task(yaml, &mut issues), EntityKind::AgentTask(_) => validate_agent_task(yaml, &mut issues), _ => {} } issues } fn validate_agent(yaml: &str, issues: &mut Vec) { match serde_yaml::from_str::(yaml) { Ok(agent) => { if agent.name.is_empty() { issues.push(ValidationIssue { level: IssueLevel::Error, field: Some("name".into()), message: "Agent name is required".into(), }); } if agent.executable.is_empty() { issues.push(ValidationIssue { level: IssueLevel::Error, field: Some("executable".into()), message: "Agent executable is required".into(), }); } let valid_executables = ["claude-code", "ollama", "openai-compat"]; if !valid_executables.contains(&agent.executable.as_str()) && !agent.executable.starts_with('/') && !agent.executable.contains('/') { issues.push(ValidationIssue { level: IssueLevel::Warning, field: Some("executable".into()), message: format!( "Executable '{}' is not a known executor. Expected one of: {:?} or an absolute path", agent.executable, valid_executables ), }); } } Err(e) => { issues.push(ValidationIssue { level: IssueLevel::Error, field: None, message: format!("Invalid agent frontmatter: {e}"), }); } } } fn validate_skill(yaml: &str, issues: &mut Vec) { match serde_yaml::from_str::(yaml) { Ok(skill) => { if skill.name.is_empty() { issues.push(ValidationIssue { level: IssueLevel::Error, field: Some("name".into()), message: "Skill name is required".into(), }); } if skill.description.is_empty() { issues.push(ValidationIssue { level: IssueLevel::Warning, field: Some("description".into()), message: "Skill should have a description".into(), }); } } Err(e) => { issues.push(ValidationIssue { level: IssueLevel::Error, field: None, message: format!("Invalid skill frontmatter: {e}"), }); } } } fn validate_cron(yaml: &str, issues: &mut Vec) { match serde_yaml::from_str::(yaml) { Ok(cron) => { if cron.title.is_empty() { issues.push(ValidationIssue { level: IssueLevel::Error, field: Some("title".into()), message: "Cron title is required".into(), }); } if cron.agent.is_empty() { issues.push(ValidationIssue { level: IssueLevel::Error, field: Some("agent".into()), message: "Cron agent is required".into(), }); } // Validate cron expression let expr = if cron.schedule.split_whitespace().count() == 5 { format!("0 {}", cron.schedule) } else { cron.schedule.clone() }; if cron::Schedule::from_str(&expr).is_err() { issues.push(ValidationIssue { level: IssueLevel::Error, field: Some("schedule".into()), message: format!("Invalid cron expression: '{}'", cron.schedule), }); } } Err(e) => { issues.push(ValidationIssue { level: IssueLevel::Error, field: None, message: format!("Invalid cron frontmatter: {e}"), }); } } } fn validate_human_task(yaml: &str, issues: &mut Vec) { match serde_yaml::from_str::(yaml) { Ok(task) => { if task.title.is_empty() { issues.push(ValidationIssue { level: IssueLevel::Error, field: Some("title".into()), message: "Task title is required".into(), }); } } Err(e) => { issues.push(ValidationIssue { level: IssueLevel::Error, field: None, message: format!("Invalid task frontmatter: {e}"), }); } } } fn validate_agent_task(yaml: &str, issues: &mut Vec) { match serde_yaml::from_str::(yaml) { Ok(task) => { if task.title.is_empty() { issues.push(ValidationIssue { level: IssueLevel::Error, field: Some("title".into()), message: "Task title is required".into(), }); } if task.agent.is_empty() { issues.push(ValidationIssue { level: IssueLevel::Error, field: Some("agent".into()), message: "Agent name is required for agent tasks".into(), }); } } Err(e) => { issues.push(ValidationIssue { level: IssueLevel::Error, field: None, message: format!("Invalid agent task frontmatter: {e}"), }); } } } /// Validate that references between entities are valid. /// Checks that agent skills and cron agents exist. pub fn validate_references( vault_root: &Path, agent_names: &HashSet, skill_names: &HashSet, ) -> Vec<(String, ValidationIssue)> { let mut issues = Vec::new(); // Check agent skill references let agents_dir = vault_root.join("agents"); if let Ok(files) = crate::filesystem::list_md_files(&agents_dir) { for path in files { if let Ok(entity) = crate::filesystem::read_entity::(&path) { for skill in &entity.frontmatter.skills { if !skill_names.contains(skill) { issues.push(( entity.frontmatter.name.clone(), ValidationIssue { level: IssueLevel::Warning, field: Some("skills".into()), message: format!("Referenced skill '{}' not found", skill), }, )); } } } } } // Check cron agent references let crons_dir = vault_root.join("crons/active"); if let Ok(files) = crate::filesystem::list_md_files(&crons_dir) { for path in files { if let Ok(entity) = crate::filesystem::read_entity::(&path) { if !agent_names.contains(&entity.frontmatter.agent) { issues.push(( entity.frontmatter.title.clone(), ValidationIssue { level: IssueLevel::Warning, field: Some("agent".into()), message: format!( "Referenced agent '{}' not found", entity.frontmatter.agent ), }, )); } } } } issues } use std::str::FromStr; #[cfg(test)] mod tests { use super::*; use std::path::Path; #[test] fn test_validate_valid_agent() { let content = "---\nname: test-agent\nexecutable: claude-code\n---\nBody"; let issues = validate(Path::new("agents/test-agent.md"), content); assert!(issues.is_empty(), "Expected no issues: {:?}", issues); } #[test] fn test_validate_agent_missing_name() { let content = "---\nname: \"\"\nexecutable: claude-code\n---\n"; let issues = validate(Path::new("agents/bad.md"), content); assert!(issues.iter().any(|i| i.field.as_deref() == Some("name"))); } #[test] fn test_validate_missing_frontmatter() { let content = "No frontmatter here"; let issues = validate(Path::new("agents/bad.md"), content); assert_eq!(issues.len(), 1); assert_eq!(issues[0].level, IssueLevel::Error); } #[test] fn test_validate_cron_bad_expression() { let content = "---\ntitle: bad\nagent: test\nschedule: \"not a cron\"\n---\n"; let issues = validate(Path::new("crons/active/bad.md"), content); assert!(issues .iter() .any(|i| i.field.as_deref() == Some("schedule"))); } }