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