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)]
27pub enum RemoveMode {
28 Archive,
30 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
50pub 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 pub fn resolve_ref(&self, name: &str, bundle: Option<&str>) -> Result<SkillRef, ServiceError> {
77 Ok(reference::resolve(self.config, name, bundle)?)
78 }
79
80 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 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 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 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 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 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}