1use 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#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SkillSummary {
20 pub r#ref: SkillRef,
21 pub directory: PathBuf,
22 pub frontmatter: SkillFrontmatter,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum RemoveMode {
29 Archive,
31 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
51pub 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 pub fn resolve_ref(&self, name: &str, bundle: Option<&str>) -> Result<SkillRef, ServiceError> {
78 Ok(reference::resolve(self.config, name, bundle)?)
79 }
80
81 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 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 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 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 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 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}