1use crate::value::VmDictExt;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use super::frontmatter::{parse_frontmatter, split_frontmatter, SkillManifest};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub enum Layer {
26 Cli,
27 Env,
28 Project,
29 Manifest,
30 User,
31 Package,
32 System,
33 Host,
34}
35
36impl Layer {
37 pub fn label(self) -> &'static str {
38 match self {
39 Layer::Cli => "cli",
40 Layer::Env => "env",
41 Layer::Project => "project",
42 Layer::Manifest => "manifest",
43 Layer::User => "user",
44 Layer::Package => "package",
45 Layer::System => "system",
46 Layer::Host => "host",
47 }
48 }
49
50 pub fn from_label(label: &str) -> Option<Layer> {
51 match label {
52 "cli" => Some(Layer::Cli),
53 "env" => Some(Layer::Env),
54 "project" => Some(Layer::Project),
55 "manifest" => Some(Layer::Manifest),
56 "user" => Some(Layer::User),
57 "package" => Some(Layer::Package),
58 "system" => Some(Layer::System),
59 "host" => Some(Layer::Host),
60 _ => None,
61 }
62 }
63
64 pub const fn all() -> &'static [Layer] {
65 &[
66 Layer::Cli,
67 Layer::Env,
68 Layer::Project,
69 Layer::Manifest,
70 Layer::User,
71 Layer::Package,
72 Layer::System,
73 Layer::Host,
74 ]
75 }
76}
77
78#[derive(Debug, Clone)]
81pub struct Skill {
82 pub manifest: SkillManifest,
83 pub body: String,
87 pub skill_dir: Option<PathBuf>,
90 pub layer: Layer,
92 pub namespace: Option<String>,
94 pub unknown_fields: Vec<String>,
97}
98
99impl Skill {
100 pub fn id(&self) -> String {
104 match &self.namespace {
105 Some(ns) if !ns.is_empty() => format!("{ns}/{}", self.manifest.name),
106 _ => self.manifest.name.clone(),
107 }
108 }
109}
110
111pub trait SkillSource: Send + Sync {
114 fn list(&self) -> Vec<SkillManifestRef>;
117
118 fn fetch(&self, id: &str) -> Result<Skill, String>;
121
122 fn layer(&self) -> Layer;
124
125 fn describe(&self) -> String;
127}
128
129#[derive(Debug, Clone)]
132pub struct SkillManifestRef {
133 pub id: String,
134 pub manifest: SkillManifest,
135 pub layer: Layer,
136 pub namespace: Option<String>,
137 pub origin: String,
138 pub unknown_fields: Vec<String>,
139}
140
141const COMMAND_FRONTMATTER_FIELDS: &[&str] = &["hooks", "command", "run"];
142
143pub fn strip_untrusted_command_frontmatter(entry: &mut crate::value::DictMap) -> bool {
146 if !has_failed_provenance(entry) {
147 return false;
148 }
149 let mut stripped = false;
150 for key in COMMAND_FRONTMATTER_FIELDS {
151 stripped |= entry.remove(*key).is_some();
152 }
153 stripped
154}
155
156fn has_failed_provenance(entry: &crate::value::DictMap) -> bool {
157 let Some(provenance) = entry
158 .get("provenance")
159 .and_then(crate::value::VmValue::as_dict)
160 else {
161 return false;
162 };
163 let signed = matches!(
164 provenance.get("signed"),
165 Some(crate::value::VmValue::Bool(true))
166 );
167 let trusted = matches!(
168 provenance.get("trusted"),
169 Some(crate::value::VmValue::Bool(true))
170 );
171 let verified_status = match provenance.get("status") {
172 Some(crate::value::VmValue::String(status)) => &**status == "verified",
173 Some(_) => false,
174 None => signed && trusted,
175 };
176 !(signed && trusted && verified_status)
177}
178
179#[derive(Debug, Clone)]
186pub struct FsSkillSource {
187 pub root: PathBuf,
188 pub layer: Layer,
189 pub namespace: Option<String>,
194}
195
196impl FsSkillSource {
197 pub fn new(root: impl Into<PathBuf>, layer: Layer) -> Self {
198 Self {
199 root: root.into(),
200 layer,
201 namespace: None,
202 }
203 }
204
205 pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
206 let ns = namespace.into();
207 self.namespace = if ns.is_empty() { None } else { Some(ns) };
208 self
209 }
210
211 fn iter_skill_dirs(&self) -> Vec<PathBuf> {
212 let mut results = Vec::new();
213 if !self.root.is_dir() {
214 return results;
215 }
216 if self.root.join("SKILL.md").is_file() {
219 results.push(self.root.clone());
220 return results;
221 }
222 let Ok(entries) = fs::read_dir(&self.root) else {
224 return results;
225 };
226 for entry in entries.flatten() {
227 let path = entry.path();
228 if !path.is_dir() {
229 continue;
230 }
231 if path.join("SKILL.md").is_file() {
232 results.push(path);
233 }
234 }
235 results.sort();
236 results
237 }
238
239 fn finalize_manifest(
240 &self,
241 dir: &Path,
242 skill_file: &Path,
243 manifest: &mut SkillManifest,
244 ) -> Result<(), String> {
245 if manifest.name.is_empty() {
246 if let Some(name) = dir.file_name().and_then(|n| n.to_str()) {
247 manifest.name = name.to_string();
248 }
249 }
250 if manifest.name.is_empty() {
251 return Err(format!(
252 "{}: SKILL.md has no `name` field and directory has no basename",
253 skill_file.display()
254 ));
255 }
256 if manifest.short.trim().is_empty() {
257 return Err(format!(
258 "{}: SKILL.md requires a non-empty `short` field",
259 skill_file.display()
260 ));
261 }
262 Ok(())
263 }
264
265 fn load_manifest_from_dir(&self, dir: &Path) -> Result<SkillManifestRef, String> {
266 let skill_file = dir.join("SKILL.md");
267 let source = fs::read_to_string(&skill_file)
268 .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
269 let (fm, _) = split_frontmatter(&source);
270 let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
271 let mut manifest = parsed.manifest;
272 self.finalize_manifest(dir, &skill_file, &mut manifest)?;
273 let id = match &self.namespace {
274 Some(ns) if !ns.is_empty() => format!("{ns}/{}", manifest.name),
275 _ => manifest.name.clone(),
276 };
277 Ok(SkillManifestRef {
278 id,
279 manifest,
280 layer: self.layer,
281 namespace: self.namespace.clone(),
282 origin: dir.display().to_string(),
283 unknown_fields: parsed.unknown_fields,
284 })
285 }
286
287 fn load_from_dir(&self, dir: &Path) -> Result<Skill, String> {
288 let skill_file = dir.join("SKILL.md");
289 let source = fs::read_to_string(&skill_file)
290 .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
291 let (fm, body) = split_frontmatter(&source);
292 let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
293 let mut manifest = parsed.manifest;
294 self.finalize_manifest(dir, &skill_file, &mut manifest)?;
295 let skill = Skill {
296 body: body.to_string(),
297 skill_dir: Some(dir.to_path_buf()),
298 layer: self.layer,
299 namespace: self.namespace.clone(),
300 unknown_fields: parsed.unknown_fields,
301 manifest,
302 };
303 Ok(skill)
304 }
305}
306
307impl SkillSource for FsSkillSource {
308 fn list(&self) -> Vec<SkillManifestRef> {
309 let mut out = Vec::new();
310 for dir in self.iter_skill_dirs() {
311 match self.load_manifest_from_dir(&dir) {
312 Ok(skill) => {
313 out.push(skill);
314 }
315 Err(err) => {
316 eprintln!("warning: skills: {err}");
317 }
318 }
319 }
320 out
321 }
322
323 fn fetch(&self, id: &str) -> Result<Skill, String> {
324 for dir in self.iter_skill_dirs() {
325 let skill = self.load_from_dir(&dir)?;
326 if skill.id() == id || (self.namespace.is_none() && skill.manifest.name == id) {
327 return Ok(skill);
328 }
329 }
330 Err(format!(
331 "skill '{id}' not found under {}",
332 self.root.display()
333 ))
334 }
335
336 fn layer(&self) -> Layer {
337 self.layer
338 }
339
340 fn describe(&self) -> String {
341 match &self.namespace {
342 Some(ns) => format!("{} [{}] ns={ns}", self.root.display(), self.layer.label()),
343 None => format!("{} [{}]", self.root.display(), self.layer.label()),
344 }
345 }
346}
347
348pub fn validate_skill_bundle(path: impl AsRef<Path>) -> Result<Skill, String> {
351 let candidate = path.as_ref();
352 let dir = if candidate.is_file() {
353 if candidate.file_name().and_then(|name| name.to_str()) != Some("SKILL.md") {
354 return Err(format!(
355 "{} is a file; expected SKILL.md or a skill directory",
356 candidate.display(),
357 ));
358 }
359 candidate
360 .parent()
361 .ok_or_else(|| format!("{} has no containing skill directory", candidate.display()))?
362 } else {
363 candidate
364 };
365 if !dir.is_dir() {
366 return Err(format!("skill directory does not exist: {}", dir.display()));
367 }
368 FsSkillSource::new(dir, Layer::Cli).load_from_dir(dir)
369}
370
371pub type HostSkillLister = Arc<dyn Fn() -> Vec<SkillManifestRef> + Send + Sync>;
374
375pub type HostSkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
378
379pub struct HostSkillSource {
383 loader: HostSkillLister,
384 fetcher: HostSkillFetcher,
385}
386
387impl HostSkillSource {
388 pub fn new<L, F>(loader: L, fetcher: F) -> Self
389 where
390 L: Fn() -> Vec<SkillManifestRef> + Send + Sync + 'static,
391 F: Fn(&str) -> Result<Skill, String> + Send + Sync + 'static,
392 {
393 Self {
394 loader: Arc::new(loader),
395 fetcher: Arc::new(fetcher),
396 }
397 }
398}
399
400impl std::fmt::Debug for HostSkillSource {
401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 f.debug_struct("HostSkillSource").finish_non_exhaustive()
403 }
404}
405
406impl SkillSource for HostSkillSource {
407 fn list(&self) -> Vec<SkillManifestRef> {
408 (self.loader)()
409 }
410
411 fn fetch(&self, id: &str) -> Result<Skill, String> {
412 (self.fetcher)(id)
413 }
414
415 fn layer(&self) -> Layer {
416 Layer::Host
417 }
418
419 fn describe(&self) -> String {
420 "host-provided [host]".to_string()
421 }
422}
423
424pub fn skill_entry_to_vm(skill: &Skill) -> crate::value::VmValue {
428 use crate::value::VmValue;
429
430 let mut entry: crate::value::DictMap = crate::value::DictMap::new();
431 entry.put_str("name", skill.manifest.name.as_str());
432 entry.put_str("short", skill.manifest.short.as_str());
433 entry.put_str(
434 "description",
435 if skill.manifest.description.is_empty() {
436 skill.manifest.short.as_str()
437 } else {
438 skill.manifest.description.as_str()
439 },
440 );
441 entry.put_opt_str("when_to_use", skill.manifest.when_to_use.as_deref());
442 if skill.manifest.disable_model_invocation {
443 entry.insert(
444 crate::value::intern_key("disable_model_invocation"),
445 VmValue::Bool(true),
446 );
447 }
448 if !skill.manifest.allowed_tools.is_empty() {
449 entry.insert(
450 crate::value::intern_key("allowed_tools"),
451 VmValue::List(std::sync::Arc::new(
452 skill
453 .manifest
454 .allowed_tools
455 .iter()
456 .map(|t| VmValue::String(arcstr::ArcStr::from(t.as_str())))
457 .collect(),
458 )),
459 );
460 }
461 if skill.manifest.user_invocable {
462 entry.insert(
463 crate::value::intern_key("user_invocable"),
464 VmValue::Bool(true),
465 );
466 }
467 if !skill.manifest.paths.is_empty() {
468 entry.insert(
469 crate::value::intern_key("paths"),
470 VmValue::List(std::sync::Arc::new(
471 skill
472 .manifest
473 .paths
474 .iter()
475 .map(|p| VmValue::String(arcstr::ArcStr::from(p.as_str())))
476 .collect(),
477 )),
478 );
479 }
480 entry.put_opt_str("context", skill.manifest.context.as_deref());
481 entry.put_opt_str("agent", skill.manifest.agent.as_deref());
482 if !skill.manifest.hooks.is_empty() {
483 let mut hooks: crate::value::DictMap = crate::value::DictMap::new();
484 for (k, v) in &skill.manifest.hooks {
485 hooks.insert(
486 crate::value::intern_key(k),
487 VmValue::String(arcstr::ArcStr::from(v.as_str())),
488 );
489 }
490 entry.insert(crate::value::intern_key("hooks"), VmValue::dict(hooks));
491 }
492 entry.put_opt_str("model", skill.manifest.model.as_deref());
493 entry.put_opt_str("effort", skill.manifest.effort.as_deref());
494 if skill.manifest.require_signature {
495 entry.insert(
496 crate::value::intern_key("require_signature"),
497 VmValue::Bool(true),
498 );
499 }
500 if !skill.manifest.trusted_signers.is_empty() {
501 entry.insert(
502 crate::value::intern_key("trusted_signers"),
503 VmValue::List(std::sync::Arc::new(
504 skill
505 .manifest
506 .trusted_signers
507 .iter()
508 .map(|fingerprint| VmValue::String(arcstr::ArcStr::from(fingerprint.as_str())))
509 .collect(),
510 )),
511 );
512 }
513 entry.put_opt_str("shell", skill.manifest.shell.as_deref());
514 entry.put_opt_str("argument_hint", skill.manifest.argument_hint.as_deref());
515 entry.put_opt_str("targets", skill.manifest.targets.as_deref());
516 if !skill.manifest.mcp.is_empty() {
517 entry.insert(
518 crate::value::intern_key("mcp"),
519 VmValue::List(std::sync::Arc::new(
520 skill
521 .manifest
522 .mcp
523 .iter()
524 .map(crate::json_to_vm_value)
525 .collect(),
526 )),
527 );
528 }
529 entry.put_str("body", skill.body.as_str());
530 if let Some(dir) = &skill.skill_dir {
531 entry.put_str("skill_dir", dir.display().to_string());
532 }
533 entry.put_str("source", skill.layer.label());
534 entry.put_opt_str("namespace", skill.namespace.as_deref());
535 VmValue::dict(entry)
536}
537
538pub fn skill_manifest_ref_to_vm(skill: &SkillManifestRef) -> crate::value::VmValue {
539 use crate::value::VmValue;
540
541 let mut entry: crate::value::DictMap = crate::value::DictMap::new();
542 entry.put_str("name", skill.manifest.name.as_str());
543 entry.put_str("short", skill.manifest.short.as_str());
544 entry.put_str(
545 "description",
546 if skill.manifest.description.is_empty() {
547 skill.manifest.short.as_str()
548 } else {
549 skill.manifest.description.as_str()
550 },
551 );
552 entry.put_opt_str("when_to_use", skill.manifest.when_to_use.as_deref());
553 if skill.manifest.disable_model_invocation {
554 entry.insert(
555 crate::value::intern_key("disable_model_invocation"),
556 VmValue::Bool(true),
557 );
558 }
559 if !skill.manifest.allowed_tools.is_empty() {
560 entry.insert(
561 crate::value::intern_key("allowed_tools"),
562 VmValue::List(std::sync::Arc::new(
563 skill
564 .manifest
565 .allowed_tools
566 .iter()
567 .map(|tool| VmValue::String(arcstr::ArcStr::from(tool.as_str())))
568 .collect(),
569 )),
570 );
571 }
572 if skill.manifest.user_invocable {
573 entry.insert(
574 crate::value::intern_key("user_invocable"),
575 VmValue::Bool(true),
576 );
577 }
578 if !skill.manifest.paths.is_empty() {
579 entry.insert(
580 crate::value::intern_key("paths"),
581 VmValue::List(std::sync::Arc::new(
582 skill
583 .manifest
584 .paths
585 .iter()
586 .map(|path| VmValue::String(arcstr::ArcStr::from(path.as_str())))
587 .collect(),
588 )),
589 );
590 }
591 entry.put_opt_str("context", skill.manifest.context.as_deref());
592 entry.put_opt_str("agent", skill.manifest.agent.as_deref());
593 if !skill.manifest.hooks.is_empty() {
594 let mut hooks: crate::value::DictMap = crate::value::DictMap::new();
595 for (key, value) in &skill.manifest.hooks {
596 hooks.insert(
597 crate::value::intern_key(key),
598 VmValue::String(arcstr::ArcStr::from(value.as_str())),
599 );
600 }
601 entry.insert(crate::value::intern_key("hooks"), VmValue::dict(hooks));
602 }
603 entry.put_opt_str("model", skill.manifest.model.as_deref());
604 entry.put_opt_str("effort", skill.manifest.effort.as_deref());
605 entry.put_opt_str("shell", skill.manifest.shell.as_deref());
606 entry.put_opt_str("argument_hint", skill.manifest.argument_hint.as_deref());
607 entry.put_opt_str("targets", skill.manifest.targets.as_deref());
608 if !skill.manifest.mcp.is_empty() {
609 entry.insert(
610 crate::value::intern_key("mcp"),
611 VmValue::List(std::sync::Arc::new(
612 skill
613 .manifest
614 .mcp
615 .iter()
616 .map(crate::json_to_vm_value)
617 .collect(),
618 )),
619 );
620 }
621 entry.put_str("source", skill.layer.label());
622 entry.put_opt_str("namespace", skill.namespace.as_deref());
623 VmValue::dict(entry)
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use std::fs;
630
631 fn write(tmp: &Path, rel: &str, body: &str) {
632 let p = tmp.join(rel);
633 fs::create_dir_all(p.parent().unwrap()).unwrap();
634 fs::write(p, body).unwrap();
635 }
636
637 #[test]
638 fn fs_source_walks_one_level_deep() {
639 let tmp = tempfile::tempdir().unwrap();
640 write(
641 tmp.path(),
642 "deploy/SKILL.md",
643 "---\nname: deploy\nshort: deploy the service\ndescription: ship it\n---\nrun deploy",
644 );
645 write(
646 tmp.path(),
647 "review/SKILL.md",
648 "---\nname: review\nshort: review a pull request\n---\nbody",
649 );
650 write(tmp.path(), "not-a-skill.txt", "no");
651
652 let src = FsSkillSource::new(tmp.path(), Layer::Project);
653 let listed = src.list();
654 assert_eq!(listed.len(), 2);
655 let names: Vec<_> = listed.iter().map(|s| s.manifest.name.clone()).collect();
656 assert!(names.contains(&"deploy".to_string()));
657 assert!(names.contains(&"review".to_string()));
658
659 let skill = src.fetch("deploy").unwrap();
660 assert_eq!(skill.manifest.short, "deploy the service");
661 assert_eq!(skill.manifest.description, "ship it");
662 assert_eq!(skill.body, "run deploy");
663 }
664
665 #[test]
666 fn mcp_servers_surface_on_the_vm_entry() {
667 let tmp = tempfile::tempdir().unwrap();
670 write(
671 tmp.path(),
672 "weather/SKILL.md",
673 "---\nname: weather\nshort: Weather lookups\nmcp-servers:\n - name: weather-mcp\n command: node\n---\nbody",
674 );
675 let src = FsSkillSource::new(tmp.path(), Layer::Project);
676 let skill = src.fetch("weather").unwrap();
677 assert_eq!(skill.manifest.mcp.len(), 1);
678
679 let entry = skill_entry_to_vm(&skill);
680 let dict = entry.as_dict().expect("entry is a dict");
681 let crate::value::VmValue::List(servers) = dict.get("mcp").expect("entry carries mcp")
682 else {
683 panic!("mcp must be a list");
684 };
685 assert_eq!(servers.len(), 1);
686 let name = servers[0]
687 .as_dict()
688 .and_then(|d| d.get("name"))
689 .map(crate::value::VmValue::display);
690 assert_eq!(name.as_deref(), Some("weather-mcp"));
691 }
692
693 #[test]
694 fn fs_source_accepts_root_as_single_skill() {
695 let tmp = tempfile::tempdir().unwrap();
696 write(
697 tmp.path(),
698 "SKILL.md",
699 "---\nname: solo\nshort: single skill bundle\n---\n(body)",
700 );
701 let src = FsSkillSource::new(tmp.path(), Layer::Cli);
702 let listed = src.list();
703 assert_eq!(listed.len(), 1);
704 assert_eq!(listed[0].manifest.name, "solo");
705 }
706
707 #[test]
708 fn fs_source_defaults_name_to_directory() {
709 let tmp = tempfile::tempdir().unwrap();
710 write(
711 tmp.path(),
712 "nameless/SKILL.md",
713 "---\nshort: fallback to the directory name\n---\nbody only",
714 );
715 let src = FsSkillSource::new(tmp.path(), Layer::User);
716 let skill = src.fetch("nameless").unwrap();
717 assert_eq!(skill.manifest.name, "nameless");
718 }
719
720 #[test]
721 fn fs_source_namespace_prefixes_id() {
722 let tmp = tempfile::tempdir().unwrap();
723 write(
724 tmp.path(),
725 "deploy/SKILL.md",
726 "---\nname: deploy\nshort: deploy the service\n---\nbody",
727 );
728 let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
729 let listed = src.list();
730 assert_eq!(listed[0].id, "acme/ops/deploy");
731 let skill = src.fetch("acme/ops/deploy").unwrap();
732 assert_eq!(skill.id(), "acme/ops/deploy");
733 }
734
735 #[test]
736 fn fs_source_namespaced_fetch_requires_qualified_id() {
737 let tmp = tempfile::tempdir().unwrap();
738 write(
739 tmp.path(),
740 "deploy/SKILL.md",
741 "---\nname: deploy\nshort: deploy the service\n---\nbody",
742 );
743 let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
744
745 assert!(src.fetch("deploy").is_err());
746 assert!(src.fetch("other/deploy").is_err());
747 assert_eq!(
748 src.fetch("acme/ops/deploy").unwrap().id(),
749 "acme/ops/deploy"
750 );
751 }
752
753 #[test]
754 fn fs_source_missing_root_is_empty_not_error() {
755 let src = FsSkillSource::new("/does/not/exist/anywhere", Layer::System);
756 assert!(src.list().is_empty());
757 assert!(src.fetch("nope").is_err());
758 }
759
760 #[test]
761 fn fs_source_requires_short_card() {
762 let tmp = tempfile::tempdir().unwrap();
763 write(
764 tmp.path(),
765 "broken/SKILL.md",
766 "---\nname: broken\n---\nbody",
767 );
768 let src = FsSkillSource::new(tmp.path(), Layer::Project);
769 assert!(src.list().is_empty());
770 let err = src.fetch("broken").unwrap_err();
771 assert!(err.contains("`short`"), "{err}");
772 }
773
774 #[test]
775 fn validate_skill_bundle_accepts_directory_or_manifest_path() {
776 let tmp = tempfile::tempdir().unwrap();
777 write(
778 tmp.path(),
779 "review/SKILL.md",
780 "---\nshort: Review developer docs\nfuture-field: retained\n---\nbody",
781 );
782 let dir = tmp.path().join("review");
783 let from_dir = validate_skill_bundle(&dir).expect("directory validates");
784 let from_file =
785 validate_skill_bundle(dir.join("SKILL.md")).expect("manifest path validates");
786 assert_eq!(from_dir.id(), "review");
787 assert_eq!(from_file.id(), "review");
788 assert_eq!(from_dir.unknown_fields, vec!["future-field"]);
789 }
790
791 #[test]
792 fn validate_skill_bundle_rejects_non_manifest_files() {
793 let tmp = tempfile::tempdir().unwrap();
794 write(tmp.path(), "notes.md", "not a skill");
795 let error = validate_skill_bundle(tmp.path().join("notes.md")).unwrap_err();
796 assert!(error.contains("expected SKILL.md"), "{error}");
797 }
798
799 #[test]
800 fn host_source_wraps_closures() {
801 let host = HostSkillSource::new(
802 || {
803 vec![SkillManifestRef {
804 id: "h1".into(),
805 manifest: SkillManifest {
806 name: "h1".into(),
807 short: "host-provided skill".into(),
808 ..Default::default()
809 },
810 layer: Layer::Host,
811 namespace: None,
812 origin: "host".into(),
813 unknown_fields: Vec::new(),
814 }]
815 },
816 |id| {
817 Ok(Skill {
818 manifest: SkillManifest {
819 name: id.to_string(),
820 short: "host-provided skill".into(),
821 ..Default::default()
822 },
823 body: "host body".into(),
824 skill_dir: None,
825 layer: Layer::Host,
826 namespace: None,
827 unknown_fields: Vec::new(),
828 })
829 },
830 );
831 assert_eq!(host.list().len(), 1);
832 let s = host.fetch("h1").unwrap();
833 assert_eq!(s.body, "host body");
834 assert_eq!(s.layer, Layer::Host);
835 }
836
837 #[test]
838 fn layer_label_roundtrips() {
839 for layer in Layer::all() {
840 assert_eq!(Layer::from_label(layer.label()), Some(*layer));
841 }
842 }
843}