1use serde::{Deserialize, Serialize};
4
5pub type SkillId = String;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SkillDefinition {
11 pub id: SkillId,
13
14 pub name: String,
16
17 pub description: String,
19
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub license: Option<String>,
23
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub compatibility: Option<String>,
27
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub metadata: Option<serde_json::Value>,
31
32 pub prompt: String,
34
35 #[serde(default)]
37 pub tool_refs: Vec<String>,
38}
39
40impl SkillDefinition {
41 pub fn new(
43 id: impl Into<String>,
44 name: impl Into<String>,
45 description: impl Into<String>,
46 prompt: impl Into<String>,
47 ) -> Self {
48 Self {
49 id: id.into(),
50 name: name.into(),
51 description: description.into(),
52 license: None,
53 compatibility: None,
54 metadata: None,
55 prompt: prompt.into(),
56 tool_refs: Vec::new(),
57 }
58 }
59
60 pub fn with_tool_ref(mut self, tool_ref: impl Into<String>) -> Self {
62 self.tool_refs.push(tool_ref.into());
63 self
64 }
65
66 pub fn is_builtin(&self) -> bool {
68 self.id.starts_with("builtin-") || self.id.starts_with("system-")
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SkillStoreConfig {
75 pub skills_dir: std::path::PathBuf,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub project_dir: Option<std::path::PathBuf>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub active_mode: Option<String>,
91}
92
93impl Default for SkillStoreConfig {
94 fn default() -> Self {
95 Self {
96 skills_dir: bamboo_infrastructure::paths::bamboo_dir().join("skills"),
99 project_dir: None,
100 active_mode: None,
101 }
102 }
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct SkillFilter {
108 pub search: Option<String>,
110}
111
112impl SkillFilter {
113 pub fn new() -> Self {
115 Self::default()
116 }
117
118 pub fn with_search(mut self, search: impl Into<String>) -> Self {
120 self.search = Some(search.into());
121 self
122 }
123
124 pub fn matches(&self, skill: &SkillDefinition) -> bool {
126 if let Some(ref search) = self.search {
127 let search_lower = search.to_lowercase();
128 if !skill.name.to_lowercase().contains(&search_lower)
129 && !skill.description.to_lowercase().contains(&search_lower)
130 {
131 return false;
132 }
133 }
134
135 true
136 }
137}
138
139#[derive(Debug, thiserror::Error)]
141pub enum SkillError {
142 #[error("Skill not found: {0}")]
143 NotFound(SkillId),
144
145 #[error("Skill already exists: {0}")]
146 AlreadyExists(SkillId),
147
148 #[error("Invalid skill ID: {0}")]
149 InvalidId(String),
150
151 #[error("Validation error: {0}")]
152 Validation(String),
153
154 #[error("Storage error: {0}")]
155 Storage(String),
156
157 #[error("Read-only: {0}")]
158 ReadOnly(String),
159
160 #[error("IO error: {0}")]
161 Io(#[from] std::io::Error),
162
163 #[error("YAML error: {0}")]
164 Yaml(#[from] serde_yaml::Error),
165}
166
167pub type SkillResult<T> = Result<T, SkillError>;
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn test_skill_definition_new() {
176 let skill = SkillDefinition::new(
177 "test-skill",
178 "Test Skill",
179 "A test skill description",
180 "Test prompt",
181 );
182
183 assert_eq!(skill.id, "test-skill");
184 assert_eq!(skill.name, "Test Skill");
185 assert_eq!(skill.description, "A test skill description");
186 assert_eq!(skill.prompt, "Test prompt");
187 assert!(skill.license.is_none());
188 assert!(skill.compatibility.is_none());
189 assert!(skill.metadata.is_none());
190 assert!(skill.tool_refs.is_empty());
191 }
192
193 #[test]
194 fn test_skill_definition_with_tool_ref() {
195 let skill = SkillDefinition::new("skill-1", "Skill", "Description", "Prompt")
196 .with_tool_ref("tool-1")
197 .with_tool_ref("tool-2");
198
199 assert_eq!(skill.tool_refs.len(), 2);
200 assert_eq!(skill.tool_refs[0], "tool-1");
201 assert_eq!(skill.tool_refs[1], "tool-2");
202 }
203
204 #[test]
205 fn test_skill_definition_is_builtin() {
206 let builtin1 = SkillDefinition::new("builtin-test", "", "", "");
207 assert!(builtin1.is_builtin());
208
209 let builtin2 = SkillDefinition::new("system-test", "", "", "");
210 assert!(builtin2.is_builtin());
211
212 let custom = SkillDefinition::new("custom-test", "", "", "");
213 assert!(!custom.is_builtin());
214 }
215
216 #[test]
217 fn test_skill_definition_serialization() {
218 let skill = SkillDefinition {
219 id: "test-id".to_string(),
220 name: "Test".to_string(),
221 description: "Desc".to_string(),
222 license: Some("MIT".to_string()),
223 compatibility: None,
224 metadata: Some(serde_json::json!({"key": "value"})),
225 prompt: "Prompt".to_string(),
226 tool_refs: vec!["tool-1".to_string()],
227 };
228
229 let json = serde_json::to_string(&skill).unwrap();
230 assert!(json.contains("\"id\":\"test-id\""));
231 assert!(json.contains("\"license\":\"MIT\""));
232 assert!(json.contains("\"tool_refs\":[\"tool-1\"]"));
233 }
234
235 #[test]
236 fn test_skill_definition_deserialization() {
237 let json = r#"{
238 "id": "skill-1",
239 "name": "Test Skill",
240 "description": "A test",
241 "prompt": "Test prompt",
242 "tool_refs": ["bash"]
243 }"#;
244
245 let skill: SkillDefinition = serde_json::from_str(json).unwrap();
246 assert_eq!(skill.id, "skill-1");
247 assert_eq!(skill.tool_refs.len(), 1);
248 }
249
250 #[test]
251 fn test_skill_definition_debug() {
252 let skill = SkillDefinition::new("test", "", "", "");
253 let debug_str = format!("{:?}", skill);
254 assert!(debug_str.contains("SkillDefinition"));
255 assert!(debug_str.contains("test"));
256 }
257
258 #[test]
259 fn test_skill_definition_clone() {
260 let skill1 = SkillDefinition::new("test", "Name", "Desc", "Prompt");
261 let skill2 = skill1.clone();
262 assert_eq!(skill1.id, skill2.id);
263 assert_eq!(skill1.name, skill2.name);
264 }
265
266 #[test]
267 fn test_skill_store_config_default() {
268 let config = SkillStoreConfig::default();
269 assert!(config.skills_dir.to_str().unwrap().contains("skills"));
270 }
271
272 #[test]
273 fn test_skill_store_config_debug() {
274 let config = SkillStoreConfig::default();
275 let debug_str = format!("{:?}", config);
276 assert!(debug_str.contains("SkillStoreConfig"));
277 }
278
279 #[test]
280 fn test_skill_store_config_clone() {
281 let config1 = SkillStoreConfig::default();
282 let config2 = config1.clone();
283 assert_eq!(config1.skills_dir, config2.skills_dir);
284 }
285
286 #[test]
287 fn test_skill_filter_new() {
288 let filter = SkillFilter::new();
289 assert!(filter.search.is_none());
290 }
291
292 #[test]
293 fn test_skill_filter_with_search() {
294 let filter = SkillFilter::new().with_search("test");
295 assert_eq!(filter.search, Some("test".to_string()));
296 }
297
298 #[test]
299 fn test_skill_filter_matches_no_search() {
300 let filter = SkillFilter::new();
301 let skill = SkillDefinition::new("test", "", "", "");
302 assert!(filter.matches(&skill));
303 }
304
305 #[test]
306 fn test_skill_filter_matches_by_name() {
307 let filter = SkillFilter::new().with_search("test");
308 let skill = SkillDefinition::new("id", "Test Skill", "Description", "Prompt");
309 assert!(filter.matches(&skill));
310 }
311
312 #[test]
313 fn test_skill_filter_matches_by_description() {
314 let filter = SkillFilter::new().with_search("search");
315 let skill = SkillDefinition::new("id", "Name", "Search here", "Prompt");
316 assert!(filter.matches(&skill));
317 }
318
319 #[test]
320 fn test_skill_filter_no_match() {
321 let filter = SkillFilter::new().with_search("xyz");
322 let skill = SkillDefinition::new("id", "Name", "Description", "Prompt");
323 assert!(!filter.matches(&skill));
324 }
325
326 #[test]
327 fn test_skill_filter_case_insensitive() {
328 let filter = SkillFilter::new().with_search("TEST");
329 let skill = SkillDefinition::new("id", "test skill", "Desc", "Prompt");
330 assert!(filter.matches(&skill));
331 }
332
333 #[test]
334 fn test_skill_error_not_found() {
335 let err = SkillError::NotFound("skill-123".to_string());
336 let msg = err.to_string();
337 assert!(msg.contains("Skill not found"));
338 assert!(msg.contains("skill-123"));
339 }
340
341 #[test]
342 fn test_skill_error_already_exists() {
343 let err = SkillError::AlreadyExists("skill-456".to_string());
344 let msg = err.to_string();
345 assert!(msg.contains("Skill already exists"));
346 }
347
348 #[test]
349 fn test_skill_error_invalid_id() {
350 let err = SkillError::InvalidId("bad id".to_string());
351 let msg = err.to_string();
352 assert!(msg.contains("Invalid skill ID"));
353 }
354
355 #[test]
356 fn test_skill_error_validation() {
357 let err = SkillError::Validation("Missing field".to_string());
358 let msg = err.to_string();
359 assert!(msg.contains("Validation error"));
360 }
361
362 #[test]
363 fn test_skill_error_storage() {
364 let err = SkillError::Storage("Disk full".to_string());
365 let msg = err.to_string();
366 assert!(msg.contains("Storage error"));
367 }
368
369 #[test]
370 fn test_skill_error_read_only() {
371 let err = SkillError::ReadOnly("Cannot delete".to_string());
372 let msg = err.to_string();
373 assert!(msg.contains("Read-only"));
374 }
375
376 #[test]
377 fn test_skill_error_io() {
378 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
379 let err = SkillError::Io(io_err);
380 let msg = err.to_string();
381 assert!(msg.contains("IO error"));
382 }
383
384 #[test]
385 fn test_skill_error_debug() {
386 let err = SkillError::NotFound("test".to_string());
387 let debug_str = format!("{:?}", err);
388 assert!(debug_str.contains("NotFound"));
389 }
390}