use crate::domain::{ProjectId, QueryId}; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; /// A single standing query. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Query { pub id: String, pub question: String, #[serde(default)] pub exit_gate: bool, } /// Synthesis query (optional, for L2). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct SynthesisQuery { pub question: String, #[serde(default)] pub exit_gate: bool, } /// Defaults applied to queries. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Defaults { #[serde(default = "default_memory_budget")] pub memory_budget: u32, #[serde(default = "default_chunk_tokens")] pub chunk_tokens: u32, #[serde(default)] pub exit_gate: bool, } fn default_memory_budget() -> u32 { 1024 } fn default_chunk_tokens() -> u32 { 5000 } impl Default for Defaults { fn default() -> Self { Self { memory_budget: default_memory_budget(), chunk_tokens: default_chunk_tokens(), exit_gate: false, } } } /// Complete set of queries for a project. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuerySet { pub project: String, pub roots: Vec, pub sources: Vec, pub queries: Vec, #[serde(default)] pub synthesis: Option, #[serde(default)] pub defaults: Defaults, } /// Load error with context. #[derive(Debug, Clone)] pub struct QueryLoadError { pub file: String, pub query_id: Option, pub field: Option, pub message: String, } impl std::fmt::Display for QueryLoadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match (&self.query_id, &self.field) { (Some(id), Some(field)) => { write!(f, "{}: query '{}', field '{}': {}", self.file, id, field, self.message) } (Some(id), None) => { write!(f, "{}: query '{}': {}", self.file, id, self.message) } (None, Some(field)) => { write!(f, "{}: field '{}': {}", self.file, field, self.message) } (None, None) => { write!(f, "{}: {}", self.file, self.message) } } } } impl std::error::Error for QueryLoadError {} /// Valid charset for query ids: lowercase, digits, hyphens only. fn is_valid_query_id(id: &str) -> bool { !id.is_empty() && id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } impl QuerySet { /// Load and validate a query set from a YAML file. pub fn load>(path: P) -> Result { let path = path.as_ref(); let filename = path.to_string_lossy().to_string(); let contents = std::fs::read_to_string(path)?; // Parse YAML let mut set: QuerySet = serde_yaml::from_str(&contents) .map_err(|e| anyhow!("Failed to parse {}: {}", filename, e))?; // Validate project if set.project.trim().is_empty() { return Err(anyhow!(QueryLoadError { file: filename, query_id: None, field: Some("project".to_string()), message: "project field is required and cannot be empty".to_string(), })); } // Validate at least one query if set.queries.is_empty() { return Err(anyhow!(QueryLoadError { file: filename, query_id: None, field: Some("queries".to_string()), message: "at least one query is required".to_string(), })); } // Validate each query let mut seen_ids = std::collections::HashSet::new(); for query in &mut set.queries { // Check ID is not empty if query.id.trim().is_empty() { return Err(anyhow!(QueryLoadError { file: filename, query_id: None, field: Some("id".to_string()), message: "query id cannot be empty".to_string(), })); } // Check ID charset if !is_valid_query_id(&query.id) { return Err(anyhow!(QueryLoadError { file: filename.clone(), query_id: Some(query.id.clone()), field: Some("id".to_string()), message: format!( "query id '{}' must match [a-z0-9-]+ (it becomes a filename)", query.id ), })); } // Check for duplicate IDs if seen_ids.contains(&query.id) { return Err(anyhow!(QueryLoadError { file: filename, query_id: Some(query.id.clone()), field: Some("id".to_string()), message: format!("duplicate query id '{}'", query.id), })); } seen_ids.insert(query.id.clone()); // Check question is not empty if query.question.trim().is_empty() { return Err(anyhow!(QueryLoadError { file: filename, query_id: Some(query.id.clone()), field: Some("question".to_string()), message: "question cannot be empty".to_string(), })); } // Apply defaults if exit_gate not set // (defaults already applied via serde default) } // Validate synthesis if present if let Some(ref synthesis) = set.synthesis { if synthesis.question.trim().is_empty() { return Err(anyhow!(QueryLoadError { file: filename, query_id: None, field: Some("synthesis.question".to_string()), message: "synthesis question cannot be empty".to_string(), })); } } // Validate defaults if set.defaults.memory_budget == 0 { return Err(anyhow!(QueryLoadError { file: filename, query_id: None, field: Some("defaults.memory_budget".to_string()), message: "memory_budget must be greater than 0".to_string(), })); } if set.defaults.chunk_tokens == 0 { return Err(anyhow!(QueryLoadError { file: filename, query_id: None, field: Some("defaults.chunk_tokens".to_string()), message: "chunk_tokens must be greater than 0".to_string(), })); } Ok(set) } /// Get a query by ID. pub fn query(&self, id: &str) -> Option<&Query> { self.queries.iter().find(|q| q.id == id) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_valid_query_id() { assert!(is_valid_query_id("architecture-decisions")); assert!(is_valid_query_id("infra-root-causes")); assert!(is_valid_query_id("id123")); assert!(is_valid_query_id("a")); assert!(is_valid_query_id("a-b-c-123")); assert!(!is_valid_query_id("")); assert!(!is_valid_query_id("infra/root-causes")); assert!(!is_valid_query_id("UPPERCASE")); assert!(!is_valid_query_id("with space")); assert!(!is_valid_query_id("with_underscore")); } #[test] fn test_defaults() { let defaults = Defaults::default(); assert_eq!(defaults.memory_budget, 1024); assert_eq!(defaults.chunk_tokens, 5000); assert!(!defaults.exit_gate); } }