1use crate::errors::ConfigError;
2use crate::model::EvalConfig;
3use std::path::Path;
4
5pub mod otel;
6pub mod path_resolver;
7pub mod resolve;
8
9pub const SUPPORTED_CONFIG_VERSION: u32 = 1;
10
11#[derive(Debug, Clone, Copy, Default)]
18pub struct LoadOptions {
19 pub legacy_mode: bool,
21 pub strict_unknown_fields: bool,
23 pub deny_ineffective_assertions: bool,
30}
31
32pub fn load_config(
35 path: &Path,
36 legacy_mode: bool,
37 strict: bool,
38) -> Result<EvalConfig, ConfigError> {
39 load_config_with(
40 path,
41 LoadOptions {
42 legacy_mode,
43 strict_unknown_fields: strict,
44 ..Default::default()
45 },
46 )
47}
48
49pub fn load_config_with(path: &Path, opts: LoadOptions) -> Result<EvalConfig, ConfigError> {
50 let LoadOptions {
51 legacy_mode,
52 strict_unknown_fields: strict,
53 deny_ineffective_assertions,
54 } = opts;
55 let raw = std::fs::read_to_string(path)
56 .map_err(|e| ConfigError(format!("failed to read config {}: {}", path.display(), e)))?;
57
58 let mut ignored_keys = std::collections::HashSet::new();
59 let deserializer = serde_yaml::Deserializer::from_str(&raw);
60
61 let mut cfg: EvalConfig = serde_ignored::deserialize(deserializer, |path| {
63 ignored_keys.insert(path.to_string());
64 })
65 .map_err(|e| ConfigError(format!("failed to parse YAML: {}", e)))?;
66
67 if strict && !ignored_keys.is_empty() {
69 let meaningful_unknowns: Vec<_> = ignored_keys
71 .iter()
72 .filter(|k| *k != "definitions" && !k.starts_with("_") && !k.starts_with("x-"))
73 .collect();
74
75 if meaningful_unknowns.is_empty() {
76 } else {
78 if ignored_keys.contains("policies") {
80 return Err(ConfigError(format!(
81 "Top-level 'policies' is not valid in configVersion: {}. Did you mean to run assay migrate on a v0 config, or remove legacy keys? (file: {})",
82 cfg.version,
83 path.display()
84 )));
85 }
86
87 return Err(ConfigError(format!(
89 "Unknown fields detected in strict mode: {:?} (file: {})",
90 meaningful_unknowns,
91 path.display()
92 )));
93 }
94 } else if !ignored_keys.is_empty() {
95 eprintln!("WARN: Ignored unknown config fields: {:?}", ignored_keys);
99 }
100
101 if legacy_mode {
103 cfg.version = 0;
104 }
105
106 if cfg.version != 0 && cfg.version != SUPPORTED_CONFIG_VERSION {
108 return Err(ConfigError(format!(
109 "unsupported config version {} (supported: 0, {})",
110 cfg.version, SUPPORTED_CONFIG_VERSION
111 )));
112 }
113
114 if cfg.tests.is_empty() {
115 return Err(ConfigError("config has no tests".into()));
116 }
117
118 if deny_ineffective_assertions {
125 let ineffective = crate::validate::ineffective_assertions(&cfg);
126 if !ineffective.is_empty() {
127 let detail = ineffective
128 .iter()
129 .map(|d| {
130 let test = d
131 .context
132 .get("test_id")
133 .and_then(|v| v.as_str())
134 .unwrap_or("?");
135 let index = d
136 .context
137 .get("assertion_index")
138 .and_then(|v| v.as_u64())
139 .map(|i| i.to_string())
140 .unwrap_or_else(|| "?".into());
141 format!("test '{}' assertion {}: {}", test, index, d.message)
142 })
143 .collect::<Vec<_>>()
144 .join("; ");
145 return Err(ConfigError(format!(
146 "{} assertion(s) cannot fail and were refused because --deny-ineffective-assertions is set ({}): {}",
147 ineffective.len(),
148 path.display(),
149 detail
150 )));
151 }
152 }
153
154 normalize_paths(&mut cfg, path)
155 .map_err(|e| ConfigError(format!("failed to normalize config paths: {}", e)))?;
156
157 Ok(cfg)
158}
159
160fn normalize_paths(cfg: &mut EvalConfig, config_path: &Path) -> anyhow::Result<()> {
161 let r = path_resolver::PathResolver::new(config_path);
162
163 for tc in &mut cfg.tests {
164 if let crate::model::Expected::JsonSchema { schema_file, .. } = &mut tc.expected {
165 if let Some(orig) = schema_file.clone() {
166 let before = orig.clone();
167 r.resolve_opt_str(schema_file);
168
169 if let Some(resolved) = schema_file.as_ref() {
170 if *resolved != before {
171 let meta = tc.metadata.get_or_insert_with(|| serde_json::json!({}));
172 if !meta.get("assay").is_some_and(|v| v.is_object()) {
173 meta["assay"] = serde_json::json!({});
174 }
175
176 meta["assay"]["schema_file_original"] = serde_json::json!(before);
177 meta["assay"]["schema_file_resolved"] = serde_json::json!(resolved);
178 meta["assay"]["config_dir"] = serde_json::json!(config_path
179 .parent()
180 .unwrap_or(Path::new("."))
181 .to_string_lossy());
182 }
183 }
184 }
185 }
186 }
187 Ok(())
188}
189
190pub fn write_sample_config(path: &Path) -> Result<(), ConfigError> {
191 std::fs::write(
192 path,
193 r#"version: 1
194suite: demo
195model: dummy
196settings:
197 parallel: 4
198 timeout_seconds: 30
199 cache: true
200tests:
201 - id: t1_must_contain
202 tags: ["smoke"]
203 input:
204 prompt: "Say hello and mention Amsterdam."
205 expected:
206 type: must_contain
207 must_contain: ["hello", "Amsterdam"]
208 - id: t2_must_not_contain
209 tags: ["smoke"]
210 input:
211 prompt: "Write a sentence without the word banana."
212 expected:
213 type: must_not_contain
214 must_not_contain: ["banana"]
215"#,
216 )
217 .map_err(|e| ConfigError(format!("failed to write sample config: {}", e)))?;
218 Ok(())
219}