vv-agent 0.4.8

VectorVein agent runtime, SDK, CLI, tools, and workspace backends
Documentation
mod diagnostics;
mod mode;
mod rules;

use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use serde_json::Value;

pub use self::diagnostics::ValidationDiagnostics;
use self::diagnostics::{append_issue, merge_diagnostics, IssueSeverity};
pub use self::mode::{
    normalize_validation_mode, ValidationMode, DEFAULT_VALIDATION_MODE, VALIDATION_MODES,
};
use self::rules::{validate_description, validate_name, ALLOWED_FIELDS};
use super::errors::SkillValidationError;
use super::parser::{find_skill_md, parse_frontmatter};

pub fn validate_metadata_with_diagnostics(
    metadata: &BTreeMap<String, Value>,
    skill_dir: Option<&Path>,
    validation_mode: Option<&str>,
) -> Result<ValidationDiagnostics, SkillValidationError> {
    let mode = normalize_validation_mode(validation_mode)?;
    let mut diagnostics = ValidationDiagnostics::default();

    let allowed = ALLOWED_FIELDS.iter().copied().collect::<BTreeSet<_>>();
    let extra_fields = metadata
        .keys()
        .filter(|key| !allowed.contains(key.as_str()))
        .cloned()
        .collect::<Vec<_>>();
    if !extra_fields.is_empty() {
        append_issue(
            &mut diagnostics,
            format!(
                "Unexpected fields in frontmatter: {}. Only {:?} are allowed.",
                extra_fields.join(", "),
                ALLOWED_FIELDS
            ),
            if mode == ValidationMode::Strict {
                IssueSeverity::Error
            } else {
                IssueSeverity::Warning
            },
        );
    }

    match metadata.get("name") {
        Some(Value::String(name)) => {
            merge_diagnostics(&mut diagnostics, validate_name(name, mode, skill_dir))
        }
        Some(_) => diagnostics
            .errors
            .push("Field 'name' must be a non-empty string".to_string()),
        None => diagnostics
            .errors
            .push("Missing required field in frontmatter: name".to_string()),
    }

    match metadata.get("description") {
        Some(Value::String(description)) => validate_description(description, &mut diagnostics),
        Some(_) => diagnostics
            .errors
            .push("Field 'description' must be a non-empty string".to_string()),
        None => diagnostics
            .errors
            .push("Missing required field in frontmatter: description".to_string()),
    }

    Ok(diagnostics)
}

pub fn validate_metadata(
    metadata: &BTreeMap<String, Value>,
    skill_dir: Option<&Path>,
    validation_mode: Option<&str>,
) -> Result<Vec<String>, SkillValidationError> {
    Ok(validate_metadata_with_diagnostics(metadata, skill_dir, validation_mode)?.errors)
}

pub fn validate_with_diagnostics(
    skill_dir: impl AsRef<Path>,
    validation_mode: Option<&str>,
) -> Result<ValidationDiagnostics, SkillValidationError> {
    let mode = normalize_validation_mode(validation_mode)?;
    let skill_dir = skill_dir.as_ref();
    let mut diagnostics = ValidationDiagnostics::default();
    if !skill_dir.exists() {
        diagnostics
            .errors
            .push(format!("Path does not exist: {}", skill_dir.display()));
        return Ok(diagnostics);
    }
    if !skill_dir.is_dir() {
        diagnostics
            .errors
            .push(format!("Not a directory: {}", skill_dir.display()));
        return Ok(diagnostics);
    }

    let Some(skill_md) = find_skill_md(skill_dir) else {
        diagnostics
            .errors
            .push("Missing required file: SKILL.md".to_string());
        return Ok(diagnostics);
    };
    let content =
        read_utf8_lossy(&skill_md).map_err(|error| SkillValidationError::new(error.to_string()))?;
    let (metadata, _) = match parse_frontmatter(&content) {
        Ok(parsed) => parsed,
        Err(error) => {
            diagnostics.errors.push(error.to_string());
            return Ok(diagnostics);
        }
    };
    validate_metadata_with_diagnostics(&metadata, Some(skill_dir), Some(mode.as_str()))
}

pub fn validate(
    skill_dir: impl AsRef<Path>,
    validation_mode: Option<&str>,
) -> Result<Vec<String>, SkillValidationError> {
    Ok(validate_with_diagnostics(skill_dir, validation_mode)?.errors)
}

fn read_utf8_lossy(path: &Path) -> Result<String, std::io::Error> {
    let bytes = std::fs::read(path)?;
    Ok(String::from_utf8_lossy(&bytes).into_owned())
}