vault-os/crates/vault-core/src/validation.rs
Harald Hoyer f820a72b04 Initial implementation of vault-os
Complete implementation across all 13 phases:

- vault-core: types, YAML frontmatter parsing, entity classification,
  filesystem ops, config, prompt composition, validation, search
- vault-watch: filesystem watcher with daemon write filtering, event
  classification
- vault-scheduler: cron engine, process executor, task runner with
  retry logic and concurrency limiting
- vault-api: Axum REST API (15 route modules), WebSocket with broadcast,
  AI assistant proxy, validation, templates
- Dashboard: React + TypeScript + Tailwind v4 with kanban, CodeMirror
  editor, dynamic view system, AI chat sidebar
- Nix flake with dev shell and NixOS module
- Graceful shutdown, inotify overflow recovery, tracing instrumentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:21:17 +01:00

317 lines
11 KiB
Rust

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<String>,
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<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
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<ValidationIssue> {
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<ValidationIssue>) {
match serde_yaml::from_str::<Agent>(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<ValidationIssue>) {
match serde_yaml::from_str::<Skill>(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<ValidationIssue>) {
match serde_yaml::from_str::<CronJob>(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<ValidationIssue>) {
match serde_yaml::from_str::<HumanTask>(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<ValidationIssue>) {
match serde_yaml::from_str::<AgentTask>(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<String>,
skill_names: &HashSet<String>,
) -> 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::<Agent>(&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::<CronJob>(&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")));
}
}