use crate::error::KowalskiError;
use crate::horde_graph::resolve_execution_graph;
use crate::horde_step::LLM_STEP_KINDS;
use crate::horde_trigger::validate_triggers;
use crate::manifest::model::{MANIFEST_SCHEMA_MAJOR, ManifestStep, WorkflowManifest};
use crate::operator_input::OperatorInputField;
use crate::rookery::{validate_horde_id, validate_step_name};
use std::collections::BTreeSet;
pub const DETERMINISTIC_STEP_KINDS: &[&str] = &["verify", "apply", "ingest", "table_profile", "sql_batch", "xlsx_report"];
pub const INTERCHANGE_STEP_KINDS: &[&str] = &["llm", "rag", "input"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ManifestStrictness {
Draft,
Publish,
}
pub fn validate_manifest(manifest: &WorkflowManifest) -> Result<Vec<String>, KowalskiError> {
validate_manifest_with(manifest, ManifestStrictness::Publish)
}
pub fn validate_manifest_with(
manifest: &WorkflowManifest,
strictness: ManifestStrictness,
) -> Result<Vec<String>, KowalskiError> {
let lenient = strictness == ManifestStrictness::Draft;
let mut errs = Vec::new();
let mut warnings = Vec::new();
match parse_schema_version(&manifest.schema_version) {
Ok((major, _minor)) => {
if major != MANIFEST_SCHEMA_MAJOR {
errs.push(format!(
"unsupported schema_version `{}` (supported MAJOR: {})",
manifest.schema_version, MANIFEST_SCHEMA_MAJOR
));
}
}
Err(e) => errs.push(e),
}
if let Err(e) = validate_horde_id(&manifest.identifier) {
errs.push(format!("identifier: {e}"));
}
if !lenient && manifest.name.trim().is_empty() {
errs.push("name must not be empty".into());
}
if !lenient && !is_semver(&manifest.version) {
errs.push(format!(
"version `{}` is not a semver (`x.y.z`)",
manifest.version
));
}
if !lenient && manifest.steps.is_empty() {
errs.push("steps must contain at least one step".into());
}
let mut step_ids = BTreeSet::new();
for step in &manifest.steps {
if !step_ids.insert(step.id.clone()) {
errs.push(format!("duplicate step id `{}`", step.id));
}
if let Err(e) = validate_step_name(&step.id) {
errs.push(format!("step id: {e}"));
}
validate_step(step, lenient, &mut errs, &mut warnings);
}
let mut seen_pipe = BTreeSet::new();
for id in &manifest.pipeline {
if !seen_pipe.insert(id.clone()) {
errs.push(format!("duplicate pipeline entry `{id}`"));
}
if !step_ids.contains(id) {
errs.push(format!("pipeline references missing step `{id}`"));
}
}
for id in &step_ids {
if !seen_pipe.contains(id) {
errs.push(format!("step `{id}` is not listed in pipeline"));
}
}
if errs.is_empty() && !(manifest.pipeline.is_empty() && manifest.edges.is_empty()) {
let edge_slice = if manifest.edges.is_empty() {
None
} else {
Some(manifest.edges.as_slice())
};
if let Err(e) = resolve_execution_graph(&manifest.pipeline, edge_slice) {
errs.push(e.to_string());
}
}
if let Some(ext) = &manifest.kowalski {
match validate_triggers(&ext.triggers, None) {
Ok(w) => warnings.extend(w),
Err(e) => errs.push(e.to_string()),
}
}
if errs.is_empty() {
Ok(warnings)
} else {
Err(KowalskiError::Validation(errs.join("; ")))
}
}
pub fn is_known_step_kind(kind: &str) -> bool {
LLM_STEP_KINDS.contains(&kind)
|| DETERMINISTIC_STEP_KINDS.contains(&kind)
|| INTERCHANGE_STEP_KINDS.contains(&kind)
}
fn validate_step(
step: &ManifestStep,
lenient: bool,
errs: &mut Vec<String>,
warnings: &mut Vec<String>,
) {
if !is_known_step_kind(&step.kind) {
warnings.push(format!(
"step `{}`: unknown kind `{}` (portability: not executable by this deployment)",
step.id, step.kind
));
}
if !lenient && step.name.trim().is_empty() {
errs.push(format!("step `{}`: name must not be empty", step.id));
}
let is_llm = LLM_STEP_KINDS.contains(&step.kind.as_str()) || step.kind == "llm";
if !lenient
&& is_llm
&& step
.agent
.as_ref()
.is_none_or(|a| a.system_prompt.trim().is_empty())
{
errs.push(format!(
"step `{}`: kind `{}` requires agent.system_prompt",
step.id, step.kind
));
}
if step.input.is_some() && step.kind != "input" {
errs.push(format!(
"step `{}`: input block is only valid on kind `input` (kind is `{}`)",
step.id, step.kind
));
}
if let Some(ext) = &step.kowalski {
if (ext.verify_command.is_some() || ext.verify_cwd.is_some()) && step.kind != "verify" {
errs.push(format!(
"step `{}`: verify_command/verify_cwd are only valid on kind `verify` (kind is `{}`)",
step.id, step.kind
));
}
if ext.apply_mode.is_some() && step.kind != "apply" {
errs.push(format!(
"step `{}`: apply_mode is only valid on kind `apply` (kind is `{}`)",
step.id, step.kind
));
}
}
for binding in &step.tool_bindings {
if binding.provider.trim().is_empty() {
errs.push(format!(
"step `{}`: tool_bindings provider must not be empty",
step.id
));
}
}
if let Some(inputs) = step.ui.as_ref().and_then(|ui| ui.get("inputs"))
&& serde_json::from_value::<Vec<OperatorInputField>>(inputs.clone()).is_err()
{
errs.push(format!(
"step `{}`: ui.inputs does not parse as operator-form fields",
step.id
));
}
}
pub(crate) fn parse_schema_version(v: &str) -> Result<(u32, u32), String> {
let bad = || format!("schema_version `{v}` must be `MAJOR.MINOR`");
let (major, minor) = v.split_once('.').ok_or_else(bad)?;
Ok((
major.parse::<u32>().map_err(|_| bad())?,
minor.parse::<u32>().map_err(|_| bad())?,
))
}
fn is_semver(v: &str) -> bool {
let core = v.split_once('-').map(|(c, _pre)| c).unwrap_or(v);
let parts: Vec<&str> = core.split('.').collect();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()))
}