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