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