1use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct Harness {
22 name: &'static str,
23}
24
25impl Harness {
26 pub(crate) const fn from_static_name(name: &'static str) -> Self {
28 Harness { name }
29 }
30
31 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#[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#[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#[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
97fn 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
139fn 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
159pub 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 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}