Skip to main content

eval_magic/core/
context.rs

1//! `RunContext` detection.
2//!
3//! `clap` owns flag parsing, so `detect_run_context` takes already-parsed
4//! values (a [`DetectInput`]) and performs the filesystem validation,
5//! sibling-skill enumeration, and path defaulting that produce a
6//! [`RunContext`].
7
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12/// The agent harness an eval runs against: a validated handle to an entry in
13/// the descriptor registry. Constructible only through registry resolution, so
14/// a held `Harness` always names a registered harness and adapter lookup never
15/// fails. The registry-dependent behavior lives next to the registry in
16/// `crate::adapters::harness`: `Harness::resolve` (the string-to-handle
17/// gateway, resolving `--harness` after parsing), `Harness::known` (every
18/// registered harness), `Default` (the registry's default harness), and
19/// `Deserialize` (resolves artifact values, rejecting unknown names).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct Harness {
22    name: &'static str,
23}
24
25impl Harness {
26    /// Registry-only constructor: `name` must be a registry entry's label.
27    pub(crate) const fn from_static_name(name: &'static str) -> Self {
28        Harness { name }
29    }
30
31    /// The kebab-case identifier (`claude-code`, `codex`, `opencode`, …) — the
32    /// `--harness` flag value and the `harness` value in artifacts.
33    pub fn name(self) -> &'static str {
34        self.name
35    }
36}
37
38impl Serialize for Harness {
39    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
40        serializer.serialize_str(self.name)
41    }
42}
43
44/// The resolved environment for a run: validated skill location, sibling skills,
45/// workspace/stage roots, optional bootstrap file, and the target harness. Built
46/// by [`detect_run_context`]; held in memory and never (de)serialized.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RunContext {
49    pub skill_dir: PathBuf,
50    pub skill_name: String,
51    pub skill_subdir: PathBuf,
52    pub sibling_skill_names: Vec<String>,
53    pub stage_siblings: bool,
54    pub workspace_root: PathBuf,
55    pub stage_root: PathBuf,
56    pub bootstrap_path: Option<PathBuf>,
57    pub harness: Harness,
58}
59
60/// Already-parsed flag values handed to [`detect_run_context`]. `clap` owns the
61/// actual argv parsing, and `--harness` is resolved against the registry before
62/// this struct is built (unknown names are rejected there); it carries the raw
63/// values through to filesystem validation and defaulting.
64#[derive(Debug, Clone, Default)]
65pub struct DetectInput {
66    pub skill_dir: Option<String>,
67    pub skill: Option<String>,
68    pub bootstrap: Option<String>,
69    pub workspace_dir: Option<String>,
70    pub harness: Option<Harness>,
71    pub cwd: Option<PathBuf>,
72}
73
74/// A user-facing failure while detecting the run context. Display strings carry
75/// the offending flag/path so the `error: <msg>` boundary in `main.rs` is
76/// actionable.
77#[derive(Debug, thiserror::Error)]
78pub enum ContextError {
79    #[error(
80        "missing skill. Run from a skill directory containing SKILL.md, pass --skill <path-or-name>, or pass --skill-dir <dir> --skill <name>"
81    )]
82    MissingSkill,
83    #[error("--skill-dir contains multiple skills; pass --skill <name>. Candidates: {0}")]
84    AmbiguousSkillSelection(String),
85    #[error("no skills found under --skill-dir: {0}")]
86    NoSkillsInSkillDir(String),
87    #[error("--skill-dir is not a directory: {0}")]
88    SkillDirNotDirectory(String),
89    #[error("skill not found: {0}")]
90    SkillNotFound(String),
91    #[error("--bootstrap file not found: {0}")]
92    BootstrapNotFound(String),
93    #[error("io error: {0}")]
94    Io(#[from] std::io::Error),
95}
96
97/// Lexically absolutize a path (join onto cwd if relative; normalize `.`/`..`).
98/// Mirrors node's `resolve()` — it does NOT resolve symlinks or require
99/// existence, unlike `std::fs::canonicalize`.
100fn absolutize(cwd: &Path, p: &str) -> Result<PathBuf, ContextError> {
101    let path = Path::new(p);
102    let joined = if path.is_absolute() {
103        path.to_path_buf()
104    } else {
105        cwd.join(path)
106    };
107    Ok(std::path::absolute(joined)?)
108}
109
110fn skill_name_from_dir(skill_subdir: &Path) -> Result<String, ContextError> {
111    skill_subdir
112        .file_name()
113        .map(|name| name.to_string_lossy().into_owned())
114        .filter(|name| !name.is_empty())
115        .ok_or(ContextError::MissingSkill)
116}
117
118fn parent_dir(skill_subdir: &Path) -> PathBuf {
119    skill_subdir
120        .parent()
121        .map(Path::to_path_buf)
122        .unwrap_or_else(|| skill_subdir.to_path_buf())
123}
124
125fn enumerate_skill_children(skill_dir: &Path) -> Result<Vec<String>, ContextError> {
126    let mut out = Vec::new();
127    for entry in std::fs::read_dir(skill_dir)? {
128        let entry = entry?;
129        let sub = entry.path();
130        if !sub.is_dir() || !sub.join("SKILL.md").exists() {
131            continue;
132        }
133        out.push(entry.file_name().to_string_lossy().into_owned());
134    }
135    out.sort();
136    Ok(out)
137}
138
139/// Other dirs in `skill_dir` (excluding the skill-under-test) that contain a
140/// `SKILL.md`. Sorted for deterministic output.
141fn enumerate_siblings(skill_dir: &Path, skill_name: &str) -> Result<Vec<String>, ContextError> {
142    Ok(enumerate_skill_children(skill_dir)?
143        .into_iter()
144        .filter(|name| name != skill_name)
145        .collect())
146}
147
148fn infer_only_skill_name(skill_dir: &Path) -> Result<String, ContextError> {
149    let skills = enumerate_skill_children(skill_dir)?;
150    match skills.as_slice() {
151        [only] => Ok(only.clone()),
152        [] => Err(ContextError::NoSkillsInSkillDir(
153            skill_dir.display().to_string(),
154        )),
155        _ => Err(ContextError::AmbiguousSkillSelection(skills.join(", "))),
156    }
157}
158
159/// Validate the parsed flags against the filesystem and assemble a
160/// [`RunContext`]: resolves either a seeded `--skill-dir` environment or a direct
161/// single skill selected from `--skill <path-or-name>` / the current directory,
162/// validates `SKILL.md`, an optional existing `--bootstrap`, and defaults the
163/// workspace/stage roots from the current directory.
164pub fn detect_run_context(input: DetectInput) -> Result<RunContext, ContextError> {
165    let cwd = input.cwd.unwrap_or(std::env::current_dir()?);
166    let cwd = std::path::absolute(cwd)?;
167    let (skill_dir, skill_name, skill_subdir, sibling_skill_names, stage_siblings) =
168        match input.skill_dir {
169            Some(skill_dir_raw) => {
170                let skill_dir = absolutize(&cwd, &skill_dir_raw)?;
171                if !skill_dir.is_dir() {
172                    return Err(ContextError::SkillDirNotDirectory(
173                        skill_dir.display().to_string(),
174                    ));
175                }
176                let skill_name = match input.skill {
177                    Some(skill) => skill,
178                    None => infer_only_skill_name(&skill_dir)?,
179                };
180                let skill_subdir = skill_dir.join(&skill_name);
181                let sibling_skill_names = enumerate_siblings(&skill_dir, &skill_name)?;
182                (
183                    skill_dir,
184                    skill_name,
185                    skill_subdir,
186                    sibling_skill_names,
187                    true,
188                )
189            }
190            None => {
191                let skill_subdir = match input.skill {
192                    Some(skill_raw) => absolutize(&cwd, &skill_raw)?,
193                    None if cwd.join("SKILL.md").exists() => cwd.clone(),
194                    None => return Err(ContextError::MissingSkill),
195                };
196                let skill_name = skill_name_from_dir(&skill_subdir)?;
197                let skill_dir = parent_dir(&skill_subdir);
198                (skill_dir, skill_name, skill_subdir, Vec::new(), false)
199            }
200        };
201    let skill_md = skill_subdir.join("SKILL.md");
202    if !skill_md.exists() {
203        return Err(ContextError::SkillNotFound(skill_md.display().to_string()));
204    }
205
206    let bootstrap_path = match input.bootstrap {
207        Some(raw) => {
208            let resolved = absolutize(&cwd, &raw)?;
209            if !resolved.exists() {
210                return Err(ContextError::BootstrapNotFound(
211                    resolved.display().to_string(),
212                ));
213            }
214            Some(resolved)
215        }
216        None => None,
217    };
218
219    let workspace_root = match input.workspace_dir {
220        Some(raw) => absolutize(&cwd, &raw)?,
221        None => cwd.join(".eval-magic"),
222    };
223    let stage_root = cwd;
224
225    let harness = input.harness.unwrap_or_default();
226
227    Ok(RunContext {
228        skill_dir,
229        skill_name,
230        skill_subdir,
231        sibling_skill_names,
232        stage_siblings,
233        workspace_root,
234        stage_root,
235        bootstrap_path,
236        harness,
237    })
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use std::fs;
244    use std::path::{Path, PathBuf};
245    use tempfile::TempDir;
246
247    /// Build `<root>/skill-dir` containing one subdir per name, each with a
248    /// `SKILL.md`, and return the skill-dir path.
249    fn make_skill_dir(root: &Path, skills: &[&str]) -> PathBuf {
250        let dir = root.join("skill-dir");
251        fs::create_dir_all(&dir).unwrap();
252        for name in skills {
253            let sub = dir.join(name);
254            fs::create_dir_all(&sub).unwrap();
255            fs::write(
256                sub.join("SKILL.md"),
257                format!("---\nname: {name}\ndescription: {name} skill\n---\n\nbody\n"),
258            )
259            .unwrap();
260        }
261        dir
262    }
263
264    fn input(skill_dir: &Path, skill: &str) -> DetectInput {
265        DetectInput {
266            skill_dir: Some(skill_dir.to_string_lossy().into_owned()),
267            skill: Some(skill.to_string()),
268            ..Default::default()
269        }
270    }
271
272    fn input_from(cwd: &Path) -> DetectInput {
273        DetectInput {
274            cwd: Some(cwd.to_path_buf()),
275            ..Default::default()
276        }
277    }
278
279    #[test]
280    fn cwd_skill_dir_is_the_default_single_skill() {
281        let tmp = TempDir::new().unwrap();
282        let skill_subdir = tmp.path().join("mr-review");
283        fs::create_dir_all(&skill_subdir).unwrap();
284        fs::write(
285            skill_subdir.join("SKILL.md"),
286            "---\nname: mr-review\n---\n\nbody\n",
287        )
288        .unwrap();
289
290        let ctx = detect_run_context(input_from(&skill_subdir)).unwrap();
291
292        assert_eq!(ctx.skill_name, "mr-review");
293        assert_eq!(
294            ctx.skill_subdir,
295            std::path::absolute(&skill_subdir).unwrap()
296        );
297        assert!(ctx.sibling_skill_names.is_empty());
298        assert!(!ctx.stage_siblings);
299    }
300
301    #[test]
302    fn skill_path_selects_one_skill_without_siblings() {
303        let tmp = TempDir::new().unwrap();
304        let skill_dir = make_skill_dir(tmp.path(), &["alpha", "beta"]);
305
306        let ctx = detect_run_context(DetectInput {
307            skill: Some(skill_dir.join("beta").to_string_lossy().into_owned()),
308            cwd: Some(tmp.path().to_path_buf()),
309            ..Default::default()
310        })
311        .unwrap();
312
313        assert_eq!(ctx.skill_name, "beta");
314        assert_eq!(
315            ctx.skill_subdir,
316            std::path::absolute(skill_dir.join("beta")).unwrap()
317        );
318        assert!(ctx.sibling_skill_names.is_empty());
319        assert!(!ctx.stage_siblings);
320    }
321
322    #[test]
323    fn skill_dir_with_one_skill_infers_the_skill_name_and_stages_siblings_mode() {
324        let tmp = TempDir::new().unwrap();
325        let skill_dir = make_skill_dir(tmp.path(), &["only-skill"]);
326
327        let ctx = detect_run_context(DetectInput {
328            skill_dir: Some(skill_dir.to_string_lossy().into_owned()),
329            cwd: Some(tmp.path().to_path_buf()),
330            ..Default::default()
331        })
332        .unwrap();
333
334        assert_eq!(ctx.skill_name, "only-skill");
335        assert!(ctx.sibling_skill_names.is_empty());
336        assert!(ctx.stage_siblings);
337    }
338
339    #[test]
340    fn skill_dir_with_multiple_skills_requires_a_skill_name() {
341        let tmp = TempDir::new().unwrap();
342        let skill_dir = make_skill_dir(tmp.path(), &["alpha", "beta"]);
343
344        let err = detect_run_context(DetectInput {
345            skill_dir: Some(skill_dir.to_string_lossy().into_owned()),
346            cwd: Some(tmp.path().to_path_buf()),
347            ..Default::default()
348        })
349        .unwrap_err();
350
351        assert!(matches!(err, ContextError::AmbiguousSkillSelection(_)));
352        assert!(err.to_string().contains("alpha"));
353        assert!(err.to_string().contains("beta"));
354    }
355
356    #[test]
357    fn missing_skill_errors_when_cwd_is_not_a_skill() {
358        let tmp = TempDir::new().unwrap();
359        let err = detect_run_context(input_from(tmp.path())).unwrap_err();
360        assert!(matches!(err, ContextError::MissingSkill));
361        assert!(err.to_string().contains("--skill"));
362    }
363
364    #[test]
365    fn empty_skill_dir_errors_when_skill_is_not_named() {
366        let tmp = TempDir::new().unwrap();
367        let skill_dir = tmp.path().join("skill-dir");
368        fs::create_dir_all(&skill_dir).unwrap();
369        let err = detect_run_context(DetectInput {
370            skill_dir: Some(skill_dir.to_string_lossy().into_owned()),
371            ..Default::default()
372        })
373        .unwrap_err();
374        assert!(matches!(err, ContextError::NoSkillsInSkillDir(_)));
375        assert!(err.to_string().contains("no skills found"));
376    }
377
378    #[test]
379    fn skill_dir_not_directory_errors() {
380        let err = detect_run_context(DetectInput {
381            skill_dir: Some("/nonexistent/does-not-exist-12345".into()),
382            skill: Some("foo".into()),
383            ..Default::default()
384        })
385        .unwrap_err();
386        assert!(matches!(err, ContextError::SkillDirNotDirectory(_)));
387        assert!(err.to_string().contains("--skill-dir"));
388    }
389
390    #[test]
391    fn skill_subdir_missing_errors() {
392        let tmp = TempDir::new().unwrap();
393        let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
394        let err = detect_run_context(input(&skill_dir, "bar")).unwrap_err();
395        assert!(matches!(err, ContextError::SkillNotFound(_)));
396        assert!(err.to_string().contains("skill not found"));
397    }
398
399    #[test]
400    fn bad_bootstrap_errors() {
401        let tmp = TempDir::new().unwrap();
402        let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
403        let err = detect_run_context(DetectInput {
404            bootstrap: Some("/nonexistent/no-bootstrap-12345.md".into()),
405            ..input(&skill_dir, "foo")
406        })
407        .unwrap_err();
408        assert!(matches!(err, ContextError::BootstrapNotFound(_)));
409        assert!(err.to_string().contains("--bootstrap"));
410    }
411
412    #[test]
413    fn happy_path_absolute_paths() {
414        let tmp = TempDir::new().unwrap();
415        let skill_dir = make_skill_dir(tmp.path(), &["mr-review"]);
416        let ctx = detect_run_context(input(&skill_dir, "mr-review")).unwrap();
417        assert_eq!(ctx.skill_dir, std::path::absolute(&skill_dir).unwrap());
418        assert_eq!(ctx.skill_name, "mr-review");
419        assert_eq!(
420            ctx.skill_subdir,
421            std::path::absolute(skill_dir.join("mr-review")).unwrap()
422        );
423        assert!(ctx.sibling_skill_names.is_empty());
424        assert!(ctx.bootstrap_path.is_none());
425        assert_eq!(ctx.harness, Harness::resolve("claude-code").unwrap());
426    }
427
428    #[test]
429    fn enumerates_siblings_excluding_sut() {
430        let tmp = TempDir::new().unwrap();
431        let skill_dir = make_skill_dir(tmp.path(), &["alpha", "beta", "gamma"]);
432        let ctx = detect_run_context(input(&skill_dir, "beta")).unwrap();
433        assert_eq!(
434            ctx.sibling_skill_names,
435            vec!["alpha".to_string(), "gamma".to_string()]
436        );
437    }
438
439    #[test]
440    fn ignores_non_skill_md_entries() {
441        let tmp = TempDir::new().unwrap();
442        let skill_dir = make_skill_dir(tmp.path(), &["real"]);
443        fs::create_dir_all(skill_dir.join("node_modules")).unwrap();
444        fs::create_dir_all(skill_dir.join("no-skill-md-here")).unwrap();
445        fs::write(skill_dir.join("loose-file.txt"), "hello").unwrap();
446        let ctx = detect_run_context(input(&skill_dir, "real")).unwrap();
447        assert!(ctx.sibling_skill_names.is_empty());
448    }
449
450    #[test]
451    fn workspace_default() {
452        let tmp = TempDir::new().unwrap();
453        let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
454        let ctx = detect_run_context(input(&skill_dir, "foo")).unwrap();
455        let expected = std::env::current_dir().unwrap().join(".eval-magic");
456        assert_eq!(ctx.workspace_root, expected);
457    }
458
459    #[test]
460    fn workspace_override_absolute() {
461        let tmp = TempDir::new().unwrap();
462        let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
463        let custom = tmp.path().join("custom-ws");
464        fs::create_dir_all(&custom).unwrap();
465        let ctx = detect_run_context(DetectInput {
466            workspace_dir: Some(custom.to_string_lossy().into_owned()),
467            ..input(&skill_dir, "foo")
468        })
469        .unwrap();
470        assert_eq!(ctx.workspace_root, std::path::absolute(&custom).unwrap());
471    }
472
473    #[test]
474    fn stage_root_default() {
475        let tmp = TempDir::new().unwrap();
476        let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
477        let ctx = detect_run_context(input(&skill_dir, "foo")).unwrap();
478        assert_eq!(ctx.stage_root, std::env::current_dir().unwrap());
479    }
480
481    #[test]
482    fn bootstrap_resolved_absolute() {
483        let tmp = TempDir::new().unwrap();
484        let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
485        let bootstrap = tmp.path().join("my-bootstrap.md");
486        fs::write(&bootstrap, "BOOT").unwrap();
487        let ctx = detect_run_context(DetectInput {
488            bootstrap: Some(bootstrap.to_string_lossy().into_owned()),
489            ..input(&skill_dir, "foo")
490        })
491        .unwrap();
492        assert_eq!(
493            ctx.bootstrap_path,
494            Some(std::path::absolute(&bootstrap).unwrap())
495        );
496    }
497
498    #[test]
499    fn harness_codex_accepted() {
500        let tmp = TempDir::new().unwrap();
501        let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
502        let ctx = detect_run_context(DetectInput {
503            harness: Some(Harness::resolve("codex").unwrap()),
504            ..input(&skill_dir, "foo")
505        })
506        .unwrap();
507        assert_eq!(ctx.harness, Harness::resolve("codex").unwrap());
508    }
509
510    #[test]
511    fn harness_opencode_accepted() {
512        let tmp = TempDir::new().unwrap();
513        let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
514        let ctx = detect_run_context(DetectInput {
515            harness: Some(Harness::resolve("opencode").unwrap()),
516            ..input(&skill_dir, "foo")
517        })
518        .unwrap();
519        assert_eq!(ctx.harness, Harness::resolve("opencode").unwrap());
520    }
521}