Skip to main content

bamboo_engine/skills/
types.rs

1//! Skill types and shared data structures.
2
3use serde::{Deserialize, Serialize};
4
5/// Unique identifier for a skill (kebab-case)
6pub type SkillId = String;
7
8/// Complete definition of a skill
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SkillDefinition {
11    /// Unique identifier (kebab-case)
12    pub id: SkillId,
13
14    /// Display name
15    pub name: String,
16
17    /// Human-readable description
18    pub description: String,
19
20    /// Optional license information from SKILL.md frontmatter
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub license: Option<String>,
23
24    /// Optional compatibility notes from SKILL.md frontmatter
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub compatibility: Option<String>,
27
28    /// Optional arbitrary metadata from SKILL.md frontmatter
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub metadata: Option<serde_json::Value>,
31
32    /// Prompt fragment injected into system prompt
33    pub prompt: String,
34
35    /// Built-in tool references (format: "tool")
36    #[serde(default)]
37    pub tool_refs: Vec<String>,
38}
39
40impl SkillDefinition {
41    /// Create a new skill definition.
42    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    /// Add a tool reference
61    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    /// Check if this is a built-in skill (based on id prefix).
67    pub fn is_builtin(&self) -> bool {
68        self.id.starts_with("builtin-") || self.id.starts_with("system-")
69    }
70}
71
72/// Configuration for skill store persistence
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SkillStoreConfig {
75    /// Global skills directory (for example: `${BAMBOO_DATA_DIR}/skills`).
76    pub skills_dir: std::path::PathBuf,
77    /// Optional workspace root used for project-local skills discovery.
78    ///
79    /// When set, Bamboo also discovers skills from:
80    /// - `<project_dir>/.bamboo/skills`
81    /// - `<project_dir>/.bamboo/skills-<active_mode>` (when `active_mode` is set)
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub project_dir: Option<std::path::PathBuf>,
84    /// Optional active mode slug for mode-specific skill overrides.
85    ///
86    /// When set, Bamboo also discovers:
87    /// - `${BAMBOO_DATA_DIR}/skills-<active_mode>`
88    /// - `<project_dir>/.bamboo/skills-<active_mode>` (if project_dir is set)
89    #[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            // Keep runtime path resolution consistent across the codebase:
97            // use BAMBOO_DATA_DIR (or `${HOME}/.bamboo`) as the single storage root.
98            skills_dir: bamboo_infrastructure::paths::bamboo_dir().join("skills"),
99            project_dir: None,
100            active_mode: None,
101        }
102    }
103}
104
105/// Filter options for listing skills
106#[derive(Debug, Clone, Default)]
107pub struct SkillFilter {
108    /// Search in name and description
109    pub search: Option<String>,
110}
111
112impl SkillFilter {
113    /// Create a new empty filter
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Set search query
119    pub fn with_search(mut self, search: impl Into<String>) -> Self {
120        self.search = Some(search.into());
121        self
122    }
123
124    /// Check if a skill matches this filter
125    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/// Error types for skill operations
140#[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
167/// Result type for skill operations
168pub 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}