Skip to main content

zeroclaw_runtime/skills/
service.rs

1//! Public service surface every consumer (CLI, gateway, future TUI) uses
2//! to read and mutate skills + skill bundles. There is no second
3//! implementation — drift is closed by construction.
4
5use std::path::{Path, PathBuf};
6
7use super::bundle::{self, BundleSummary};
8use super::constants::{
9    SKILL_ARCHIVE_DIR_NAME, SKILL_DEPRECATED_MANIFESTS, SKILL_MANIFEST_FILENAME,
10};
11use super::document::{DocumentParseError, SkillDocument};
12use super::frontmatter::SkillFrontmatter;
13use super::reference::{self, SkillRef, SkillRefError};
14use super::scaffold::{self, ScaffoldError, ScaffoldOptions};
15use zeroclaw_config::schema::Config;
16
17/// Per-skill view returned by [`SkillsService::list_skills`].
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SkillSummary {
20    pub r#ref: SkillRef,
21    pub directory: PathBuf,
22    pub frontmatter: SkillFrontmatter,
23}
24
25/// Behaviour selector for [`SkillsService::remove_skill`] and
26/// [`SkillsService::remove_bundle`].
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum RemoveMode {
29    /// Move to `<install>/shared/skills/_deleted/<name>-<unix-ts>/`.
30    Archive,
31    /// `rm -rf`. Irreversible.
32    Purge,
33}
34
35#[derive(Debug, thiserror::Error)]
36pub enum ServiceError {
37    #[error(transparent)]
38    Ref(#[from] SkillRefError),
39    #[error(transparent)]
40    Bundle(#[from] bundle::BundleError),
41    #[error(transparent)]
42    Scaffold(#[from] ScaffoldError),
43    #[error(transparent)]
44    DocumentParse(#[from] DocumentParseError),
45    #[error("skill '{0}' is not present in any configured bundle")]
46    NotFound(String),
47    #[error(transparent)]
48    Io(#[from] std::io::Error),
49}
50
51/// Single source of truth for skill + skill-bundle operations.
52///
53/// Holds an immutable reference to `Config` and the install-root path. Reads
54/// are filesystem operations against the resolved bundle directories;
55/// writes go through the matching helpers in [`super::scaffold`],
56/// [`super::bundle`], and [`super::document`] so a single rule lives in a
57/// single place.
58pub struct SkillsService<'a> {
59    config: &'a Config,
60    install_root: PathBuf,
61}
62
63impl<'a> SkillsService<'a> {
64    pub fn new(config: &'a Config, install_root: impl Into<PathBuf>) -> Self {
65        Self {
66            config,
67            install_root: install_root.into(),
68        }
69    }
70
71    pub fn install_root(&self) -> &Path {
72        &self.install_root
73    }
74
75    /// Resolve a `(name, bundle?)` pair into a unique [`SkillRef`] per the
76    /// disambiguation rule defined in [`super::reference::resolve`].
77    pub fn resolve_ref(&self, name: &str, bundle: Option<&str>) -> Result<SkillRef, ServiceError> {
78        Ok(reference::resolve(self.config, name, bundle)?)
79    }
80
81    /// One [`BundleSummary`] per configured bundle, in HashMap order.
82    pub fn list_bundles(&self) -> Result<Vec<BundleSummary>, ServiceError> {
83        let mut out = Vec::with_capacity(self.config.skill_bundles.len());
84        for (alias, cfg) in &self.config.skill_bundles {
85            let directory = bundle::resolve_directory(self.config, &self.install_root, alias)?;
86            out.push(BundleSummary {
87                alias: alias.clone(),
88                directory,
89                include: cfg.include.clone(),
90                exclude: cfg.exclude.clone(),
91            });
92        }
93        Ok(out)
94    }
95
96    /// All skills in `bundle_filter` (or all bundles when `None`). Skips any
97    /// child directory that's missing a canonical or deprecated manifest.
98    pub fn list_skills(
99        &self,
100        bundle_filter: Option<&str>,
101    ) -> Result<Vec<SkillSummary>, ServiceError> {
102        let mut out = Vec::new();
103        for summary in self.list_bundles()? {
104            if let Some(filter) = bundle_filter
105                && summary.alias != filter
106            {
107                continue;
108            }
109            let Ok(entries) = std::fs::read_dir(&summary.directory) else {
110                continue;
111            };
112            for entry in entries.flatten() {
113                let path = entry.path();
114                if !path.is_dir() {
115                    continue;
116                }
117                if !has_manifest(&path) {
118                    continue;
119                }
120                let canonical_path = path.join(SKILL_MANIFEST_FILENAME);
121                let Ok(content) = std::fs::read_to_string(&canonical_path) else {
122                    continue;
123                };
124                let Ok(doc) = SkillDocument::parse(&content) else {
125                    continue;
126                };
127                let name = path
128                    .file_name()
129                    .map(|s| s.to_string_lossy().into_owned())
130                    .unwrap_or_default();
131                out.push(SkillSummary {
132                    r#ref: SkillRef::new_unchecked(summary.alias.clone(), name),
133                    directory: path,
134                    frontmatter: doc.frontmatter,
135                });
136            }
137        }
138        Ok(out)
139    }
140
141    /// Read the `SKILL.md` for a resolved skill.
142    pub fn read_skill(&self, target: &SkillRef) -> Result<SkillDocument, ServiceError> {
143        let path = self.skill_directory(target)?.join(SKILL_MANIFEST_FILENAME);
144        let content = std::fs::read_to_string(&path).map_err(|e| {
145            if e.kind() == std::io::ErrorKind::NotFound {
146                ServiceError::NotFound(target.to_string())
147            } else {
148                ServiceError::Io(e)
149            }
150        })?;
151        Ok(SkillDocument::parse(&content)?)
152    }
153
154    /// Overwrite the `SKILL.md` for a resolved skill.
155    pub fn write_skill(&self, target: &SkillRef, doc: &SkillDocument) -> Result<(), ServiceError> {
156        let dir = self.skill_directory(target)?;
157        if !dir.exists() {
158            return Err(ServiceError::NotFound(target.to_string()));
159        }
160        std::fs::write(dir.join(SKILL_MANIFEST_FILENAME), doc.serialize())?;
161        Ok(())
162    }
163
164    /// Materialize a brand-new skill on disk per the canonical layout.
165    pub fn scaffold_skill(
166        &self,
167        target: &SkillRef,
168        frontmatter: SkillFrontmatter,
169        opts: ScaffoldOptions,
170    ) -> Result<PathBuf, ServiceError> {
171        Ok(scaffold::scaffold_skill(
172            self.config,
173            &self.install_root,
174            target,
175            frontmatter,
176            opts,
177        )?)
178    }
179
180    /// Archive or purge a skill directory.
181    pub fn remove_skill(&self, target: &SkillRef, mode: RemoveMode) -> Result<(), ServiceError> {
182        let dir = self.skill_directory(target)?;
183        if !dir.exists() {
184            return Err(ServiceError::NotFound(target.to_string()));
185        }
186        match mode {
187            RemoveMode::Purge => std::fs::remove_dir_all(&dir)?,
188            RemoveMode::Archive => {
189                let archive_root = self
190                    .install_root
191                    .join("shared")
192                    .join("skills")
193                    .join(SKILL_ARCHIVE_DIR_NAME);
194                std::fs::create_dir_all(&archive_root)?;
195                let ts = std::time::SystemTime::now()
196                    .duration_since(std::time::UNIX_EPOCH)
197                    .map(|d| d.as_secs())
198                    .unwrap_or(0);
199                let archive_name = format!("{}-{}-{}", target.bundle(), target.name(), ts);
200                std::fs::rename(&dir, archive_root.join(archive_name))?;
201            }
202        }
203        Ok(())
204    }
205
206    fn skill_directory(&self, target: &SkillRef) -> Result<PathBuf, ServiceError> {
207        let bundle_dir =
208            bundle::resolve_directory(self.config, &self.install_root, target.bundle())?;
209        Ok(bundle_dir.join(target.name()))
210    }
211}
212
213fn has_manifest(path: &Path) -> bool {
214    if path.join(SKILL_MANIFEST_FILENAME).is_file() {
215        return true;
216    }
217    SKILL_DEPRECATED_MANIFESTS
218        .iter()
219        .any(|name| path.join(name).is_file())
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use tempfile::TempDir;
226    use zeroclaw_config::schema::SkillBundleConfig;
227
228    fn fixture(bundles: &[&str]) -> (TempDir, Config) {
229        let dir = tempfile::tempdir().unwrap();
230        let mut cfg = Config::default();
231        for alias in bundles {
232            cfg.skill_bundles
233                .insert((*alias).to_string(), SkillBundleConfig::default());
234        }
235        (dir, cfg)
236    }
237
238    fn make_skill(svc: &SkillsService, bundle: &str, name: &str) -> SkillRef {
239        let target = SkillRef::new_unchecked(bundle.into(), name.into());
240        svc.scaffold_skill(
241            &target,
242            SkillFrontmatter {
243                name: name.into(),
244                description: "stub".into(),
245                ..Default::default()
246            },
247            ScaffoldOptions::default(),
248        )
249        .unwrap();
250        target
251    }
252
253    #[test]
254    fn list_bundles_includes_default_directory_for_unset_field() {
255        let (dir, cfg) = fixture(&["alpha"]);
256        let svc = SkillsService::new(&cfg, dir.path());
257        let bundles = svc.list_bundles().unwrap();
258        assert_eq!(bundles.len(), 1);
259        assert_eq!(bundles[0].alias, "alpha");
260        assert_eq!(bundles[0].directory, dir.path().join("shared/skills/alpha"),);
261    }
262
263    #[test]
264    fn list_skills_returns_empty_when_bundle_dir_absent() {
265        let (dir, cfg) = fixture(&["alpha"]);
266        let svc = SkillsService::new(&cfg, dir.path());
267        assert!(svc.list_skills(None).unwrap().is_empty());
268    }
269
270    #[test]
271    fn scaffold_then_list_round_trip() {
272        let (dir, cfg) = fixture(&["alpha"]);
273        let svc = SkillsService::new(&cfg, dir.path());
274        make_skill(&svc, "alpha", "code-review");
275        let skills = svc.list_skills(None).unwrap();
276        assert_eq!(skills.len(), 1);
277        assert_eq!(skills[0].r#ref.name(), "code-review");
278        assert_eq!(skills[0].frontmatter.description, "stub");
279    }
280
281    #[test]
282    fn list_skills_filters_by_bundle() {
283        let (dir, cfg) = fixture(&["alpha", "beta"]);
284        let svc = SkillsService::new(&cfg, dir.path());
285        make_skill(&svc, "alpha", "a-skill");
286        make_skill(&svc, "beta", "b-skill");
287        let alpha_only = svc.list_skills(Some("alpha")).unwrap();
288        assert_eq!(alpha_only.len(), 1);
289        assert_eq!(alpha_only[0].r#ref.bundle(), "alpha");
290    }
291
292    #[test]
293    fn read_and_write_round_trip_preserves_frontmatter() {
294        let (dir, cfg) = fixture(&["alpha"]);
295        let svc = SkillsService::new(&cfg, dir.path());
296        let target = make_skill(&svc, "alpha", "rw");
297
298        let mut doc = svc.read_skill(&target).unwrap();
299        doc.frontmatter.description = "updated description text".into();
300        doc.frontmatter.license = Some("MIT".into());
301        svc.write_skill(&target, &doc).unwrap();
302
303        let reread = svc.read_skill(&target).unwrap();
304        assert_eq!(reread.frontmatter.description, "updated description text");
305        assert_eq!(reread.frontmatter.license.as_deref(), Some("MIT"));
306    }
307
308    #[test]
309    fn remove_archive_moves_to_deleted_root_and_leaves_no_trace() {
310        let (dir, cfg) = fixture(&["alpha"]);
311        let svc = SkillsService::new(&cfg, dir.path());
312        let target = make_skill(&svc, "alpha", "to-archive");
313        let original_dir = dir.path().join("shared/skills/alpha/to-archive");
314        assert!(original_dir.exists());
315
316        svc.remove_skill(&target, RemoveMode::Archive).unwrap();
317        assert!(!original_dir.exists());
318        let archive_root = dir.path().join("shared/skills/_deleted");
319        assert!(archive_root.is_dir());
320        let archived: Vec<_> = std::fs::read_dir(&archive_root)
321            .unwrap()
322            .filter_map(|e| e.ok())
323            .collect();
324        assert_eq!(archived.len(), 1);
325    }
326
327    #[test]
328    fn remove_purge_deletes_outright() {
329        let (dir, cfg) = fixture(&["alpha"]);
330        let svc = SkillsService::new(&cfg, dir.path());
331        let target = make_skill(&svc, "alpha", "to-purge");
332        let original_dir = dir.path().join("shared/skills/alpha/to-purge");
333        svc.remove_skill(&target, RemoveMode::Purge).unwrap();
334        assert!(!original_dir.exists());
335        assert!(!dir.path().join("shared/skills/_deleted").exists());
336    }
337
338    #[test]
339    fn read_skill_errors_with_not_found_for_missing_skill() {
340        let (dir, cfg) = fixture(&["alpha"]);
341        let svc = SkillsService::new(&cfg, dir.path());
342        let target = SkillRef::new_unchecked("alpha".into(), "ghost".into());
343        let err = svc.read_skill(&target).unwrap_err();
344        assert!(matches!(err, ServiceError::NotFound(_)));
345    }
346}