zeroclaw_runtime/skills/
reference.rs1use std::fmt;
11
12use zeroclaw_config::schema::Config;
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub struct SkillRef {
19 bundle: String,
20 name: String,
21}
22
23impl SkillRef {
24 pub(super) fn new_unchecked(bundle: String, name: String) -> Self {
25 Self { bundle, name }
26 }
27
28 pub fn bundle(&self) -> &str {
29 &self.bundle
30 }
31
32 pub fn name(&self) -> &str {
33 &self.name
34 }
35}
36
37impl fmt::Display for SkillRef {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 write!(f, "{}/{}", self.bundle, self.name)
40 }
41}
42
43#[derive(Debug, thiserror::Error)]
46pub enum SkillRefError {
47 #[error("no skill bundles are configured; create one before adding skills")]
48 NoBundles,
49
50 #[error("skill bundle '{0}' is not configured")]
51 UnknownBundle(String),
52
53 #[error("skill '{name}' was not found in any configured bundle")]
54 UnknownSkill { name: String },
55
56 #[error(
57 "skill name '{name}' is ambiguous across bundles {candidates:?}; pass --bundle to disambiguate"
58 )]
59 AmbiguousName {
60 name: String,
61 candidates: Vec<String>,
62 },
63}
64
65pub fn resolve(
75 config: &Config,
76 name: &str,
77 bundle: Option<&str>,
78) -> Result<SkillRef, SkillRefError> {
79 if config.skill_bundles.is_empty() {
80 return Err(SkillRefError::NoBundles);
81 }
82
83 if let Some(bundle_alias) = bundle {
84 if !config.skill_bundles.contains_key(bundle_alias) {
85 return Err(SkillRefError::UnknownBundle(bundle_alias.to_string()));
86 }
87 return Ok(SkillRef::new_unchecked(
88 bundle_alias.to_string(),
89 name.to_string(),
90 ));
91 }
92
93 if config.skill_bundles.len() == 1 {
94 let bundle_alias = config.skill_bundles.keys().next().unwrap().clone();
95 return Ok(SkillRef::new_unchecked(bundle_alias, name.to_string()));
96 }
97
98 Err(SkillRefError::AmbiguousName {
99 name: name.to_string(),
100 candidates: config.skill_bundles.keys().cloned().collect(),
101 })
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use zeroclaw_config::schema::SkillBundleConfig;
108
109 fn cfg_with_bundles(aliases: &[&str]) -> Config {
110 let mut cfg = Config::default();
111 for alias in aliases {
112 cfg.skill_bundles
113 .insert((*alias).to_string(), SkillBundleConfig::default());
114 }
115 cfg
116 }
117
118 #[test]
119 fn errors_when_no_bundles_configured() {
120 let cfg = Config::default();
121 assert!(matches!(
122 resolve(&cfg, "anything", None),
123 Err(SkillRefError::NoBundles),
124 ));
125 }
126
127 #[test]
128 fn errors_on_unknown_bundle_when_qualified() {
129 let cfg = cfg_with_bundles(&["alpha"]);
130 let err = resolve(&cfg, "name", Some("beta")).unwrap_err();
131 assert!(matches!(err, SkillRefError::UnknownBundle(b) if b == "beta"));
132 }
133
134 #[test]
135 fn auto_resolves_when_single_bundle() {
136 let cfg = cfg_with_bundles(&["alpha"]);
137 let r = resolve(&cfg, "code-review", None).unwrap();
138 assert_eq!(r.bundle(), "alpha");
139 assert_eq!(r.name(), "code-review");
140 }
141
142 #[test]
143 fn errors_on_ambiguity_when_multiple_bundles_and_no_qualifier() {
144 let cfg = cfg_with_bundles(&["alpha", "beta"]);
145 let err = resolve(&cfg, "code-review", None).unwrap_err();
146 let candidates = match err {
147 SkillRefError::AmbiguousName { candidates, .. } => candidates,
148 other => panic!("expected AmbiguousName, got {other:?}"),
149 };
150 assert_eq!(candidates.len(), 2);
151 assert!(candidates.iter().any(|c| c == "alpha"));
152 assert!(candidates.iter().any(|c| c == "beta"));
153 }
154
155 #[test]
156 fn qualified_resolves_in_multi_bundle_config() {
157 let cfg = cfg_with_bundles(&["alpha", "beta"]);
158 let r = resolve(&cfg, "code-review", Some("beta")).unwrap();
159 assert_eq!(r.bundle(), "beta");
160 assert_eq!(r.name(), "code-review");
161 }
162}