eval_magic/adapters/descriptor/
layers.rs1use std::fs;
13use std::path::{Path, PathBuf};
14
15use super::{DescriptorError, EMBEDDED_DESCRIPTORS};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Layer {
21 Embedded,
23 UserGlobal,
27 ProjectLocal,
29 HarnessFile,
31}
32
33impl Layer {
34 pub fn display_name(self) -> &'static str {
36 match self {
37 Layer::Embedded => "built-in",
38 Layer::UserGlobal => "user",
39 Layer::ProjectLocal => "project",
40 Layer::HarnessFile => "file",
41 }
42 }
43}
44
45#[derive(Debug, Clone)]
48pub struct DescriptorSource {
49 pub layer: Layer,
50 pub path: String,
51 pub toml_src: String,
52}
53
54pub fn embedded_sources() -> Vec<DescriptorSource> {
56 EMBEDDED_DESCRIPTORS
57 .iter()
58 .map(|(path, toml_src)| DescriptorSource {
59 layer: Layer::Embedded,
60 path: (*path).to_string(),
61 toml_src: (*toml_src).to_string(),
62 })
63 .collect()
64}
65
66#[derive(Debug, thiserror::Error)]
69#[error("cannot read --harness-file {path}: {message}")]
70pub struct HarnessFileError {
71 pub path: String,
72 pub message: String,
73}
74
75pub fn discover_sources(
81 config_root: Option<&Path>,
82 project_root: &Path,
83 harness_file: Option<&Path>,
84) -> Result<(Vec<DescriptorSource>, Vec<String>), HarnessFileError> {
85 let mut warnings = Vec::new();
86 let mut sources = embedded_sources();
87 if let Some(config_root) = config_root {
88 sources.extend(dir_sources(
89 &config_root.join("harnesses"),
90 Layer::UserGlobal,
91 &mut warnings,
92 ));
93 }
94 sources.extend(dir_sources(
95 &project_root.join(".eval-magic").join("harnesses"),
96 Layer::ProjectLocal,
97 &mut warnings,
98 ));
99 if let Some(file) = harness_file {
100 let toml_src = fs::read_to_string(file).map_err(|e| HarnessFileError {
101 path: file.display().to_string(),
102 message: e.to_string(),
103 })?;
104 sources.push(DescriptorSource {
105 layer: Layer::HarnessFile,
106 path: file.display().to_string(),
107 toml_src,
108 });
109 }
110 Ok((sources, warnings))
111}
112
113fn dir_sources(dir: &Path, layer: Layer, warnings: &mut Vec<String>) -> Vec<DescriptorSource> {
115 let Ok(entries) = fs::read_dir(dir) else {
116 return Vec::new();
118 };
119 let mut paths: Vec<PathBuf> = entries
120 .flatten()
121 .map(|e| e.path())
122 .filter(|p| p.extension().is_some_and(|ext| ext == "toml") && p.is_file())
123 .collect();
124 paths.sort();
125 paths
126 .into_iter()
127 .filter_map(|path| match fs::read_to_string(&path) {
128 Ok(toml_src) => Some(DescriptorSource {
129 layer,
130 path: path.display().to_string(),
131 toml_src,
132 }),
133 Err(e) => {
134 warnings.push(format!(
135 "skipping unreadable harness descriptor {}: {e}",
136 path.display()
137 ));
138 None
139 }
140 })
141 .collect()
142}
143
144pub fn config_root_from(
148 explicit: Option<&str>,
149 xdg_config_home: Option<&str>,
150 home: Option<&Path>,
151) -> Option<PathBuf> {
152 if let Some(explicit) = explicit {
153 if explicit.is_empty() {
154 return None;
155 }
156 return Some(PathBuf::from(explicit));
157 }
158 if let Some(xdg) = xdg_config_home
159 && !xdg.is_empty()
160 {
161 return Some(Path::new(xdg).join("eval-magic"));
162 }
163 home.map(|home| home.join(".config").join("eval-magic"))
164}
165
166pub fn default_config_root() -> Option<PathBuf> {
168 config_root_from(
169 std::env::var("EVAL_MAGIC_CONFIG_DIR").ok().as_deref(),
170 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
171 std::env::home_dir().as_deref(),
172 )
173}
174
175pub fn check_user_layer_restrictions(
182 value: &serde_json::Value,
183 path: &str,
184) -> Result<(), DescriptorError> {
185 let declares_guard = value.get("guard").is_some();
186 let flips_support = value
187 .pointer("/run/supports_guard")
188 .and_then(serde_json::Value::as_bool)
189 == Some(true);
190 if declares_guard || flips_support {
191 return Err(DescriptorError::Invariant {
192 path: path.to_string(),
193 message: "user-supplied descriptors may not declare [guard] or set \
194 run.supports_guard = true — the write guard fails open, so a mistyped \
195 guard block would silently disarm it; guard data stays restricted to \
196 built-in descriptors. Remove it; unguarded runs fall back to the \
197 detect-stray-writes audit."
198 .to_string(),
199 });
200 }
201 Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use std::fs;
208 use std::path::Path;
209
210 #[test]
211 fn embedded_sources_cover_every_bundled_descriptor() {
212 let sources = embedded_sources();
213 assert_eq!(sources.len(), EMBEDDED_DESCRIPTORS.len());
214 assert!(sources.iter().all(|s| s.layer == Layer::Embedded));
215 assert_eq!(sources[0].path, "harnesses/claude-code.toml");
216 assert!(!sources[0].toml_src.is_empty());
217 }
218
219 #[test]
220 fn user_layer_may_not_declare_a_guard_table() {
221 let value: serde_json::Value =
224 toml::from_str("label = \"demo\"\n\n[guard]\narmed_message = \"x\"\n").unwrap();
225 let err = check_user_layer_restrictions(&value, "user.toml")
226 .unwrap_err()
227 .to_string();
228 assert!(err.contains("user.toml"), "{err}");
229 assert!(err.contains("may not declare [guard]"), "{err}");
230 assert!(
231 err.contains("detect-stray-writes"),
232 "names the fallback: {err}"
233 );
234 }
235
236 #[test]
237 fn user_layer_may_not_flip_supports_guard_on() {
238 let value: serde_json::Value =
239 toml::from_str("label = \"demo\"\n\n[run]\nsupports_guard = true\n").unwrap();
240 let err = check_user_layer_restrictions(&value, "user.toml")
241 .unwrap_err()
242 .to_string();
243 assert!(err.contains("run.supports_guard"), "{err}");
244 }
245
246 #[test]
247 fn user_layer_restrictions_pass_everything_else() {
248 let value: serde_json::Value = toml::from_str(
249 "label = \"demo\"\n\n[run]\nsupports_guard = false\n\n[model]\nflag = \"-m\"\n",
250 )
251 .unwrap();
252 assert!(check_user_layer_restrictions(&value, "user.toml").is_ok());
253 }
254
255 #[test]
256 fn config_root_prefers_explicit_env_then_xdg_then_home() {
257 assert_eq!(
258 config_root_from(Some("/explicit"), Some("/xdg"), Some(Path::new("/home/u"))),
259 Some("/explicit".into())
260 );
261 assert_eq!(
262 config_root_from(None, Some("/xdg"), Some(Path::new("/home/u"))),
263 Some("/xdg/eval-magic".into())
264 );
265 assert_eq!(
266 config_root_from(None, None, Some(Path::new("/home/u"))),
267 Some("/home/u/.config/eval-magic".into())
268 );
269 assert_eq!(config_root_from(None, None, None), None);
270 }
271
272 #[test]
273 fn empty_config_dir_env_disables_the_user_layer() {
274 assert_eq!(
275 config_root_from(Some(""), Some("/xdg"), Some(Path::new("/home/u"))),
276 None
277 );
278 }
279
280 #[test]
281 fn discovery_layers_in_precedence_order() {
282 let tmp = tempfile::TempDir::new().unwrap();
283 let config_root = tmp.path().join("config");
284 let project_root = tmp.path().join("project");
285 let user_dir = config_root.join("harnesses");
286 let project_dir = project_root.join(".eval-magic").join("harnesses");
287 fs::create_dir_all(&user_dir).unwrap();
288 fs::create_dir_all(&project_dir).unwrap();
289 fs::write(user_dir.join("b.toml"), "label = \"bee\"\n").unwrap();
291 fs::write(user_dir.join("a.toml"), "label = \"ay\"\n").unwrap();
292 fs::write(user_dir.join("notes.md"), "not a descriptor").unwrap();
293 fs::write(project_dir.join("p.toml"), "label = \"pea\"\n").unwrap();
294 let file = tmp.path().join("one-off.toml");
295 fs::write(&file, "label = \"one-off\"\n").unwrap();
296
297 let (sources, warnings) =
298 discover_sources(Some(&config_root), &project_root, Some(&file)).unwrap();
299 assert!(warnings.is_empty(), "{warnings:?}");
300 let tail: Vec<(Layer, &str)> = sources[EMBEDDED_DESCRIPTORS.len()..]
301 .iter()
302 .map(|s| (s.layer, s.path.as_str()))
303 .collect();
304 assert_eq!(sources[0].layer, Layer::Embedded);
305 assert_eq!(
306 tail,
307 vec![
308 (Layer::UserGlobal, user_dir.join("a.toml").to_str().unwrap()),
309 (Layer::UserGlobal, user_dir.join("b.toml").to_str().unwrap()),
310 (
311 Layer::ProjectLocal,
312 project_dir.join("p.toml").to_str().unwrap()
313 ),
314 (Layer::HarnessFile, file.to_str().unwrap()),
315 ]
316 );
317 }
318
319 #[test]
320 fn discovery_tolerates_missing_layer_dirs() {
321 let tmp = tempfile::TempDir::new().unwrap();
322 let (sources, warnings) = discover_sources(
323 Some(&tmp.path().join("nope")),
324 &tmp.path().join("also-nope"),
325 None,
326 )
327 .unwrap();
328 assert_eq!(sources.len(), EMBEDDED_DESCRIPTORS.len());
329 assert!(warnings.is_empty(), "{warnings:?}");
330 }
331
332 #[test]
333 fn discovery_fails_hard_on_an_unreadable_harness_file() {
334 let tmp = tempfile::TempDir::new().unwrap();
335 let missing = tmp.path().join("missing.toml");
336 let err = discover_sources(None, tmp.path(), Some(&missing))
337 .unwrap_err()
338 .to_string();
339 assert!(err.contains("--harness-file"), "{err}");
340 assert!(err.contains("missing.toml"), "{err}");
341 }
342}