Skip to main content

zeroclaw_runtime/skills/
reference.rs

1//! Skill identity + the disambiguation rule that every surface goes through.
2//!
3//! `SkillRef` is the canonical `(bundle, name)` pair. Fields are private; the
4//! only public constructor is [`resolve`], which enforces the rule "bundle
5//! optional when name is globally unique across configured bundles". CLI flag
6//! parsing, gateway URL parsing, TUI selection — all must call `resolve` to
7//! produce a `SkillRef`. If a future caller hand-builds one, they cannot:
8//! the constructor is module-private.
9
10use std::fmt;
11
12use zeroclaw_config::schema::Config;
13
14/// Canonical `(bundle-alias, skill-name)` identity for a skill on disk.
15///
16/// Construct via [`resolve`]; never by literal field assignment.
17#[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/// Errors surfaced by [`resolve`] when a `(name, bundle?)` pair cannot be
44/// turned into a unique `SkillRef`.
45#[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
65/// Resolve a `(name, bundle?)` pair into a canonical [`SkillRef`].
66///
67/// Rule: `bundle` is optional iff `name` exists in exactly one configured
68/// bundle's directory. Otherwise the caller must qualify.
69///
70/// Filesystem state (which directories actually contain a `SKILL.md`) is
71/// checked by [`crate::skills::service::SkillsService::list_skills`]; this
72/// function operates over `Config` alone and is filesystem-free, so it can
73/// be unit-tested in isolation.
74pub 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}