//! Agent spec parsing + validation. //! //! An agent spec is a markdown file with YAML frontmatter. The required //! fields mirror the format documented in `CLAUDE.md`. Validation fails fast //! on missing fields so a broken spec never reaches a runtime. use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::Path; use thiserror::Error; use walkdir::WalkDir; #[derive(Debug, Error)] pub enum SpecError { #[error("missing required field '{0}' in {1}")] MissingField(&'static str, String), #[error("could not read file {0}: {1}")] Io(String, String), #[error("could not parse frontmatter in {0}: {1}")] Parse(String, String), } /// The frontmatter of an agent spec. Mirrors `agents//.md`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentSpec { pub name: String, pub tier: String, pub department: String, pub reports_to: String, pub autonomy: String, #[serde(default)] pub skills: Vec, #[serde(default)] pub tools: Vec, #[serde(default)] pub forbidden: Vec, #[serde(default)] pub budget: Budget, #[serde(default)] pub language: Language, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Budget { #[serde(default)] pub max_eur_per_task: f64, #[serde(default)] pub max_tokens: u64, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Language { #[serde(default = "default_chat")] pub chat: String, #[serde(default = "default_deliverables")] pub deliverables: String, } fn default_chat() -> String { "tr".into() } fn default_deliverables() -> String { "en".into() } const REQUIRED_FIELDS: &[&str] = &["name", "tier", "department", "reports_to", "autonomy"]; /// Walks `agents/` and parses every `.md` into an `AgentSpec`. /// /// Returns the specs + any validation errors. A spec with errors is excluded /// from the success list and reported in the error vec. pub fn validate_agents(root: &Path) -> Result<(Vec, Vec)> { let mut specs = Vec::new(); let mut errors = Vec::new(); for entry in WalkDir::new(root).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("md") { continue; } let rel = path.strip_prefix(root).unwrap_or(path).display().to_string(); let content = match std::fs::read_to_string(path) { Ok(c) => c, Err(e) => { errors.push(SpecError::Io(rel, e.to_string())); continue; } }; let frontmatter = match extract_frontmatter(&content) { Some(fm) => fm, None => { errors.push(SpecError::Parse(rel, "no --- frontmatter block found".into())); continue; } }; // Parse the YAML frontmatter as JSON-ish: convert simple YAML to JSON. // (Agent frontmatter is flat — key: value + list items — so a minimal // conversion suffices without a full YAML parser dep.) let json_str = match yaml_to_json(frontmatter) { Ok(j) => j, Err(e) => { errors.push(SpecError::Parse(rel, e.to_string())); continue; } }; let parsed: serde_json::Value = match serde_json::from_str(&json_str) { Ok(v) => v, Err(e) => { errors.push(SpecError::Parse(rel, e.to_string())); continue; } }; // Check required fields. let obj = match parsed.as_object() { Some(o) => o, None => { errors.push(SpecError::Parse(rel, "frontmatter is not an object".into())); continue; } }; let mut missing = Vec::new(); for field in REQUIRED_FIELDS { if !obj.contains_key(*field) { missing.push(*field); } } if !missing.is_empty() { errors.push(SpecError::MissingField(missing[0], rel)); continue; } // Deserialize into the typed struct (best-effort; ignore unknown fields). let spec: AgentSpec = match serde_json::from_value(parsed) { Ok(s) => s, Err(e) => { errors.push(SpecError::Parse(rel, e.to_string())); continue; } }; specs.push(spec); } Ok((specs, errors)) } /// Extracts the content between `---` fences at the top of a markdown file. fn extract_frontmatter(content: &str) -> Option<&str> { let trimmed = content.trim_start(); if !trimmed.starts_with("---") { return None; } let after_first_fence = &trimmed[3..]; let end = after_first_fence.find("\n---")?; Some(after_first_fence[..end].trim()) } /// Minimal YAML→JSON converter for flat agent-spec frontmatter. /// /// Supports only the subset we use: `key: value`, `key: [a, b, c]` inline /// arrays, and block arrays (`- item`). Nested objects (like `budget:` with /// sub-keys) and `language:` are handled with a simple indentation stack. /// This avoids pulling a full YAML parser dep into the scaffold. fn yaml_to_json(yaml: &str) -> Result { let mut out = String::from("{"); let lines: Vec<&str> = yaml.lines().collect(); let mut i = 0; while i < lines.len() { let line = lines[i].trim_end(); if line.trim().is_empty() || line.trim_start().starts_with('#') { i += 1; continue; } let (key, rest) = match line.split_once(':') { Some((k, v)) => (k.trim(), v.trim()), None => { i += 1; continue; } }; if rest.is_empty() { // Could be a block array (`- item`) or a nested object. // Peek ahead: collect comment + dash lines as array items. let mut items: Vec = Vec::new(); let mut j = i + 1; while j < lines.len() { let l = lines[j].trim(); if l.is_empty() { j += 1; continue; } // Skip comment lines inside a block array. if l.starts_with('#') { j += 1; continue; } if !l.starts_with('-') { break; } let val = l.trim_start_matches('-').trim(); items.push(quote_or_passthrough(val)); j += 1; } if !items.is_empty() { out.push_str(&format!("\"{key}\":[{}],", items.join(","))); i = j; continue; } // Nested object: collect indented lines until dedent. let mut sub = String::from("{"); while j < lines.len() { let l = lines[j]; if !l.starts_with(' ') && !l.starts_with('\t') { break; } if let Some((sk, sv)) = l.trim().split_once(':') { sub.push_str(&format!( "\"{}\":{},", sk.trim(), quote_or_passthrough(sv.trim()) )); } j += 1; } sub.pop(); // trailing comma sub.push('}'); out.push_str(&format!("\"{key}\":{sub},")); i = j; continue; } // Inline array? `[a, b, c]` if rest.starts_with('[') { let inner = rest .trim_start_matches('[') .trim_end_matches(']') .split(',') .map(|s| quote_or_passthrough(s.trim())) .collect::>() .join(","); out.push_str(&format!("\"{key}\":[{inner}],")); } else { out.push_str(&format!( "\"{key}\":{},", quote_or_passthrough(rest) )); } i += 1; } if out.ends_with(',') { out.pop(); } out.push('}'); Ok(out) } /// Quotes a value as a JSON string unless it looks like a number or bool. fn quote_or_passthrough(v: &str) -> String { let v = v.trim(); if v.is_empty() { return "\"\"".into(); } if v == "true" || v == "false" || v == "null" { return v.into(); } if v.parse::().is_ok() { return v.into(); } // Strip surrounding quotes if present, then re-add as JSON string. let v = v.trim_matches('"'); format!("\"{}\"", v.replace('\\', "\\\\").replace('"', "\\\"")) }