selfware 0.6.2

Your personal AI workshop — software you own, software that lasts
use super::ast::{
    GuardCondition, Guardrail, ReduceStage, SwlDocument, WorkflowDefinition, WorkflowStep,
    WorkflowType,
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationIssue {
    pub path: String,
    pub message: String,
}

impl ValidationIssue {
    fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            message: message.into(),
        }
    }
}

pub fn validate_document(doc: &SwlDocument) -> Vec<ValidationIssue> {
    let mut issues = Vec::new();

    if doc.version.trim().is_empty() {
        issues.push(ValidationIssue::new("version", "version must not be empty"));
    }
    if doc.name.trim().is_empty() {
        issues.push(ValidationIssue::new("name", "name must not be empty"));
    }
    if doc.agents.is_empty() {
        issues.push(ValidationIssue::new(
            "agents",
            "document must define at least one agent",
        ));
    }
    if doc.workflows.is_empty() {
        issues.push(ValidationIssue::new(
            "workflows",
            "document must define at least one workflow",
        ));
    }

    for (agent_name, agent) in &doc.agents {
        if agent.model.name().trim().is_empty() {
            issues.push(ValidationIssue::new(
                format!("agents.{agent_name}.model"),
                "model must not be empty",
            ));
        }

        for (tool_idx, tool) in agent.tools.iter().enumerate() {
            if tool.trim().is_empty() {
                issues.push(ValidationIssue::new(
                    format!("agents.{agent_name}.tools.{tool_idx}"),
                    "tool name must not be empty",
                ));
            }
        }

        if let Some(output_key) = &agent.output_key {
            if output_key.trim().is_empty() {
                issues.push(ValidationIssue::new(
                    format!("agents.{agent_name}.output_key"),
                    "output_key must not be empty",
                ));
            }
        }
    }

    for (workflow_name, workflow) in &doc.workflows {
        let path = format!("workflows.{workflow_name}");
        validate_workflow(doc, workflow, &path, &mut issues);
    }

    for (idx, guardrail) in doc.guardrails.iter().enumerate() {
        let path = format!("guardrails.{idx}");

        if guardrail
            .name
            .as_ref()
            .is_none_or(|name| name.trim().is_empty())
        {
            issues.push(ValidationIssue::new(
                format!("{path}.name"),
                "guardrail name must not be empty",
            ));
        }

        if guardrail
            .guardrail_type
            .as_ref()
            .is_none_or(|guardrail_type| guardrail_type.trim().is_empty())
        {
            issues.push(ValidationIssue::new(
                format!("{path}.type"),
                "guardrail type must not be empty",
            ));
        }

        validate_guardrail(guardrail, &path, &mut issues);
    }

    if let Some(state) = &doc.state {
        for (idx, field) in state.fields.iter().enumerate() {
            if field.name.trim().is_empty() {
                issues.push(ValidationIssue::new(
                    format!("state.fields.{idx}.name"),
                    "state field name must not be empty",
                ));
            }
        }
    }

    if let Some(telemetry) = &doc.telemetry {
        if let Some(export) = &telemetry.export {
            if export.export_type.trim().is_empty() {
                issues.push(ValidationIssue::new(
                    "telemetry.export.type",
                    "telemetry export type must not be empty",
                ));
            }

            if export
                .path
                .as_deref()
                .is_some_and(|path| path.trim().is_empty())
            {
                issues.push(ValidationIssue::new(
                    "telemetry.export.path",
                    "telemetry export path must not be empty",
                ));
            }
        }
    }

    issues
}

fn validate_workflow(
    doc: &SwlDocument,
    workflow: &WorkflowDefinition,
    path: &str,
    issues: &mut Vec<ValidationIssue>,
) {
    if matches!(workflow.workflow_type, WorkflowType::MapReduce) {
        if workflow.map.is_none() {
            issues.push(ValidationIssue::new(
                format!("{path}.map"),
                "map-reduce workflow requires a map stage",
            ));
        }
        if workflow.reduce.is_none() {
            issues.push(ValidationIssue::new(
                format!("{path}.reduce"),
                "map-reduce workflow requires a reduce stage",
            ));
        }
    }

    for (idx, step) in workflow.steps.iter().enumerate() {
        validate_step(doc, step, &format!("{path}.steps.{idx}"), issues);
    }

    if let Some(map) = &workflow.map {
        if map.targets.is_empty() && map.parallel.is_none() {
            issues.push(ValidationIssue::new(
                format!("{path}.map"),
                "map stage must define targets or parallel branches",
            ));
        }

        for target in &map.targets {
            if !doc.agents.contains_key(target) {
                issues.push(ValidationIssue::new(
                    format!("{path}.map.targets"),
                    format!("unknown agent target '{target}'"),
                ));
            }
        }

        if let Some(parallel) = &map.parallel {
            if parallel.branches.is_empty() {
                issues.push(ValidationIssue::new(
                    format!("{path}.map.parallel.branches"),
                    "parallel branches must not be empty",
                ));
            }

            for (idx, step) in parallel.branches.iter().enumerate() {
                validate_step(
                    doc,
                    step,
                    &format!("{path}.map.parallel.branches.{idx}"),
                    issues,
                );
            }
        }
    }

    if let Some(reduce) = &workflow.reduce {
        match reduce {
            ReduceStage::Code(block) => {
                if block.code.trim().is_empty() {
                    issues.push(ValidationIssue::new(
                        format!("{path}.reduce.code"),
                        "reduce code must not be empty",
                    ));
                }
            }
            ReduceStage::Aggregate(stage) => {
                if !doc.agents.contains_key(&stage.agent) {
                    issues.push(ValidationIssue::new(
                        format!("{path}.reduce.agent"),
                        format!("unknown agent target '{}'", stage.agent),
                    ));
                }
            }
        }
    }

    if let Some(merge) = &workflow.merge {
        if !doc.agents.contains_key(&merge.agent) {
            issues.push(ValidationIssue::new(
                format!("{path}.merge.agent"),
                format!("unknown agent target '{}'", merge.agent),
            ));
        }
    }
}

fn validate_step(
    doc: &SwlDocument,
    step: &WorkflowStep,
    path: &str,
    issues: &mut Vec<ValidationIssue>,
) {
    let kinds = usize::from(step.delegate.is_some())
        + usize::from(step.agent.is_some())
        + usize::from(step.parallel.is_some())
        + usize::from(step.guard.is_some());

    if kinds == 0 {
        issues.push(ValidationIssue::new(
            path,
            "step must define one of delegate, agent, parallel, or guard",
        ));
        return;
    }

    if kinds > 1 {
        issues.push(ValidationIssue::new(
            path,
            "step must not combine delegate, agent, parallel, and guard definitions",
        ));
    }

    if let Some(delegate) = &step.delegate {
        if !doc.agents.contains_key(delegate) {
            issues.push(ValidationIssue::new(
                format!("{path}.delegate"),
                format!("unknown agent target '{delegate}'"),
            ));
        }
    }

    if let Some(agent) = &step.agent {
        if !doc.agents.contains_key(agent) {
            issues.push(ValidationIssue::new(
                format!("{path}.agent"),
                format!("unknown agent target '{agent}'"),
            ));
        }

        if step
            .action
            .as_ref()
            .is_none_or(|action| action.trim().is_empty())
        {
            issues.push(ValidationIssue::new(
                format!("{path}.action"),
                "legacy agent steps must define a non-empty action",
            ));
        }
    }

    if let Some(parallel) = &step.parallel {
        if parallel.branches.is_empty() {
            issues.push(ValidationIssue::new(
                format!("{path}.parallel.branches"),
                "parallel branches must not be empty",
            ));
        }

        for (idx, branch) in parallel.branches.iter().enumerate() {
            validate_step(
                doc,
                branch,
                &format!("{path}.parallel.branches.{idx}"),
                issues,
            );
        }
    }

    if let Some(guard) = &step.guard {
        validate_guardrail(guard, &format!("{path}.guard"), issues);
    }
}

fn validate_guardrail(guardrail: &Guardrail, path: &str, issues: &mut Vec<ValidationIssue>) {
    match &guardrail.condition {
        GuardCondition::Inline(condition) => {
            if condition.trim().is_empty() {
                issues.push(ValidationIssue::new(
                    format!("{path}.condition"),
                    "guard condition must not be empty",
                ));
            }
        }
        GuardCondition::Code(block) => {
            if block.code.trim().is_empty() {
                issues.push(ValidationIssue::new(
                    format!("{path}.condition.code"),
                    "guard condition code must not be empty",
                ));
            }
        }
    }

    if guardrail.on_violation.trim().is_empty() {
        issues.push(ValidationIssue::new(
            format!("{path}.on_violation"),
            "on_violation must not be empty",
        ));
    }
}

#[cfg(test)]
#[path = "../../../tests/unit/swl/parser/validator/validator_test.rs"]
mod tests;