assay-core 2.10.0

High-performance evaluation framework for LLM agents (Core)
Documentation
use crate::errors::ConfigError;
use crate::model::EvalConfig;
use std::path::Path;

pub mod path_resolver;
pub mod resolve;

pub const SUPPORTED_CONFIG_VERSION: u32 = 1;

pub fn load_config(
    path: &Path,
    legacy_mode: bool,
    strict: bool,
) -> Result<EvalConfig, ConfigError> {
    let raw = std::fs::read_to_string(path)
        .map_err(|e| ConfigError(format!("failed to read config {}: {}", path.display(), e)))?;

    let mut ignored_keys = std::collections::HashSet::new();
    let deserializer = serde_yaml::Deserializer::from_str(&raw);

    // serde_ignored wrapper to capture unknown fields
    let mut cfg: EvalConfig = serde_ignored::deserialize(deserializer, |path| {
        ignored_keys.insert(path.to_string());
    })
    .map_err(|e| ConfigError(format!("failed to parse YAML: {}", e)))?;

    // Check strictness / significant unknown fields
    if strict && !ignored_keys.is_empty() {
        // Whitelist common YAML anchor keys
        let meaningful_unknowns: Vec<_> = ignored_keys
            .iter()
            .filter(|k| *k != "definitions" && !k.starts_with("_") && !k.starts_with("x-"))
            .collect();

        if meaningful_unknowns.is_empty() {
            // All unknowns are whitelisted (e.g. anchors). PASS.
        } else {
            // Special helpful error for v0 'policies'
            if ignored_keys.contains("policies") {
                return Err(ConfigError(format!(
                    "Top-level 'policies' is not valid in configVersion: {}. Did you mean to run assay migrate on a v0 config, or remove legacy keys? (file: {})",
                    cfg.version,
                    path.display()
                )));
            }

            // Generic strict error
            return Err(ConfigError(format!(
                "Unknown fields detected in strict mode: {:?} (file: {})",
                meaningful_unknowns,
                path.display()
            )));
        }
    } else if !ignored_keys.is_empty() {
        // In non-strict mode, we ideally WARN, but standard logging might not be initialized here.
        // For now, we proceed as 'careful ignore' but validated at least.
        // The user specifically asked for migrate FAIL (strict=true) and run WARN.
        eprintln!("WARN: Ignored unknown config fields: {:?}", ignored_keys);
    }

    // Legacy override
    if legacy_mode {
        cfg.version = 0;
    }

    // Allow 0 or 1
    if cfg.version != 0 && cfg.version != SUPPORTED_CONFIG_VERSION {
        return Err(ConfigError(format!(
            "unsupported config version {} (supported: 0, {})",
            cfg.version, SUPPORTED_CONFIG_VERSION
        )));
    }

    if cfg.tests.is_empty() {
        return Err(ConfigError("config has no tests".into()));
    }

    normalize_paths(&mut cfg, path)
        .map_err(|e| ConfigError(format!("failed to normalize config paths: {}", e)))?;

    Ok(cfg)
}

fn normalize_paths(cfg: &mut EvalConfig, config_path: &Path) -> anyhow::Result<()> {
    let r = path_resolver::PathResolver::new(config_path);

    for tc in &mut cfg.tests {
        if let crate::model::Expected::JsonSchema { schema_file, .. } = &mut tc.expected {
            if let Some(orig) = schema_file.clone() {
                let before = orig.clone();
                r.resolve_opt_str(schema_file);

                if let Some(resolved) = schema_file.as_ref() {
                    if *resolved != before {
                        let meta = tc.metadata.get_or_insert_with(|| serde_json::json!({}));
                        if !meta.get("assay").is_some_and(|v| v.is_object()) {
                            meta["assay"] = serde_json::json!({});
                        }

                        meta["assay"]["schema_file_original"] = serde_json::json!(before);
                        meta["assay"]["schema_file_resolved"] = serde_json::json!(resolved);
                        meta["assay"]["config_dir"] = serde_json::json!(config_path
                            .parent()
                            .unwrap_or(Path::new("."))
                            .to_string_lossy());
                    }
                }
            }
        }
    }
    Ok(())
}

pub fn write_sample_config(path: &Path) -> Result<(), ConfigError> {
    std::fs::write(
        path,
        r#"version: 1
suite: demo
model: dummy
settings:
  parallel: 4
  timeout_seconds: 30
  cache: true
tests:
  - id: t1_must_contain
    tags: ["smoke"]
    input:
      prompt: "Say hello and mention Amsterdam."
    expected:
      type: must_contain
      must_contain: ["hello", "Amsterdam"]
  - id: t2_must_not_contain
    tags: ["smoke"]
    input:
      prompt: "Write a sentence without the word banana."
    expected:
      type: must_not_contain
      must_not_contain: ["banana"]
"#,
    )
    .map_err(|e| ConfigError(format!("failed to write sample config: {}", e)))?;
    Ok(())
}