1use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::core::run_mode::{RunMode, resolve_run_mode};
13
14#[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#[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 pub run_mode: RunMode,
45}
46
47#[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#[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
86fn 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
128fn 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
148pub 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 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}