Skip to main content

eval_magic/adapters/descriptor/
layers.rs

1//! Descriptor source layers: where a descriptor's TOML text came from.
2//!
3//! Discovery order is precedence order — embedded built-ins, then user-global
4//! (`~/.config/eval-magic/harnesses/*.toml`), then project-local
5//! (`.eval-magic/harnesses/*.toml`), then a one-off `--harness-file`. Later
6//! layers override earlier ones field-by-field (see
7//! [`merge_descriptor_value`](super::merge_descriptor_value)). Every source
8//! carries its layer and display path so registry entries can report
9//! provenance (`harness list`/`show`) and error messages can name the
10//! contributing file.
11
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use super::{DescriptorError, EMBEDDED_DESCRIPTORS};
16
17/// The layer a descriptor source belongs to, in precedence order (later
18/// layers override earlier ones field-by-field).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Layer {
21    /// A built-in descriptor bundled into the binary (`harnesses/*.toml`).
22    Embedded,
23    /// A user-global descriptor (`<config-root>/harnesses/*.toml`, where the
24    /// config root is `$EVAL_MAGIC_CONFIG_DIR`, `$XDG_CONFIG_HOME/eval-magic`,
25    /// or `~/.config/eval-magic`).
26    UserGlobal,
27    /// A project-local descriptor (`<cwd>/.eval-magic/harnesses/*.toml`).
28    ProjectLocal,
29    /// A one-off descriptor passed via `--harness-file <path>`.
30    HarnessFile,
31}
32
33impl Layer {
34    /// The short name `harness list`/`show` and warnings display.
35    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/// One descriptor source: its layer, a display path for error messages and
46/// provenance, and the raw TOML text.
47#[derive(Debug, Clone)]
48pub struct DescriptorSource {
49    pub layer: Layer,
50    pub path: String,
51    pub toml_src: String,
52}
53
54/// The bundled built-in descriptors as the registry's base layer.
55pub 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/// A `--harness-file` path that could not be read. Unlike discovered layer
67/// files (skipped with a warning), the explicitly named file is a hard error.
68#[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
75/// Discover every descriptor source in precedence order: embedded →
76/// user-global → project-local → `--harness-file`. Missing layer directories
77/// are fine (empty layer); unreadable files inside a layer directory are
78/// skipped and reported in the returned warnings. Only the explicit
79/// `--harness-file` fails hard.
80pub 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
113/// Every `*.toml` in `dir`, sorted by filename for deterministic layering.
114fn dir_sources(dir: &Path, layer: Layer, warnings: &mut Vec<String>) -> Vec<DescriptorSource> {
115    let Ok(entries) = fs::read_dir(dir) else {
116        // No directory means an empty layer, not an error.
117        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
144/// Resolve the user-global config root from explicit/env inputs:
145/// `$EVAL_MAGIC_CONFIG_DIR` verbatim (empty disables the layer), else
146/// `$XDG_CONFIG_HOME/eval-magic`, else `<home>/.config/eval-magic`.
147pub 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
166/// [`config_root_from`] over the live environment.
167pub 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
175/// Reject descriptor content user-supplied files may not declare: the write
176/// guard fails open (an unrenderable or mistyped guard block silently allows
177/// everything), so guard data stays restricted to the embedded built-ins
178/// until user guards get an explicit trust story. The check runs on each
179/// file's own content, so a user overlay of a guarded built-in still inherits
180/// the embedded guard cleanly.
181pub 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        // The restriction fires on the [guard] table's presence alone — field
222        // shape is irrelevant (the schema gate owns that).
223        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        // Written out of name order to prove the filename sort.
290        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}