//! # Gyandoot Skills System //! //! Skills are reusable procedural knowledge — loaded, preprocessed, and invoked //! by the agent to perform specific tasks. Supports discovery from filesystem //! directories, YAML frontmatter parsing, template variable resolution, //! bundle archives, and remote skill hubs. //! //! ## Architecture //! //! - [`SkillsManager`]: Central registry for skill discovery, loading, and indexing. //! - [`Skill`]: A loaded skill with metadata, content, and template variables. //! - [`SkillMeta`]: Lightweight metadata for inventory/index purposes. //! - [`Preprocessor`]: Resolves `${VAR}` template placeholders with depth limits. //! - [`TrustLevel`]: Classifies skill provenance (Builtin, Trusted, Community, Unverified). //! - [`SkillBundle`]: An archive of multiple skills for distribution. //! - [`SkillHub`]: Remote fetching of skills from a hub URL. //! //! ## Example //! //! ```ignore //! use gyandoot_skills::{SkillsManager, Preprocessor}; //! use std::collections::HashMap; //! use std::path::PathBuf; //! //! let mut manager = SkillsManager::new() //! .with_skill_dir(PathBuf::from("/etc/gyandoot/skills")); //! //! let metas = manager.scan(); //! if let Some(skill) = manager.load("code-review") { //! let pp = Preprocessor::new(); //! let mut vars = HashMap::new(); //! vars.insert("FILE".to_string(), "src/main.rs".to_string()); //! let rendered = pp.process(&skill.content, &vars); //! println!("{}", rendered); //! } //! ``` use std::collections::HashMap; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; // ─── Trust Levels ──────────────────────────────────────────────────────── /// Provenance classification for a skill. /// /// Determines what level of trust the system places in the skill's content. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum TrustLevel { /// Ships with the GyanDoot binary; cannot be modified. Builtin, /// Signed or verified by a trusted source. Trusted, /// Contributed by the community, reviewed but not signed. Community, /// Sourced from unknown or untrusted origins. Unverified, } impl TrustLevel { /// Parse a trust level from a string (case-insensitive). pub fn from_str(s: &str) -> Self { match s.to_lowercase().as_str() { "builtin" => TrustLevel::Builtin, "trusted" => TrustLevel::Trusted, "community" => TrustLevel::Community, _ => TrustLevel::Unverified, } } /// Return the canonical string representation. pub fn as_str(&self) -> &'static str { match self { TrustLevel::Builtin => "builtin", TrustLevel::Trusted => "trusted", TrustLevel::Community => "community", TrustLevel::Unverified => "unverified", } } } impl std::fmt::Display for TrustLevel { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_str()) } } // ─── Metadata ──────────────────────────────────────────────────────────── /// Lightweight metadata for a discovered skill, suitable for indexing. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillMeta { /// Unique skill name (e.g. "code-review", "git-helper"). pub name: String, /// Human-readable one-line description. pub description: String, /// Optional category (derived from parent directory name). pub category: Option, /// Searchable tags. pub tags: Vec, /// Trust level classification. pub trust: TrustLevel, /// Filesystem path to the skill directory or file. pub path: PathBuf, /// Optional version string. pub version: Option, /// Optional author string. pub author: Option, } impl SkillMeta { /// Create a new skill metadata entry. pub fn new(name: impl Into, description: impl Into) -> Self { Self { name: name.into(), description: description.into(), category: None, tags: Vec::new(), trust: TrustLevel::Unverified, path: PathBuf::new(), version: None, author: None, } } /// Builder: set the category. pub fn with_category(mut self, category: impl Into) -> Self { self.category = Some(category.into()); self } /// Builder: add a tag. pub fn with_tag(mut self, tag: impl Into) -> Self { self.tags.push(tag.into()); self } /// Builder: set trust level. pub fn with_trust(mut self, trust: TrustLevel) -> Self { self.trust = trust; self } /// Builder: set path. pub fn with_path(mut self, path: PathBuf) -> Self { self.path = path; self } } // ─── Skill ─────────────────────────────────────────────────────────────── /// A loaded skill with full content and pre-parsed template variables. #[derive(Debug, Clone)] pub struct Skill { /// Parsed metadata (from YAML frontmatter or registry). pub meta: SkillMeta, /// Full skill content (SKILL.md body). pub content: String, /// Template variables extracted from the skill. pub templates: HashMap, } impl Skill { /// Create a new skill from its parts. pub fn new(meta: SkillMeta, content: impl Into) -> Self { Self { meta, content: content.into(), templates: HashMap::new(), } } /// Build a compact summary for system prompt injection. pub fn summary(&self) -> String { format!( "- {} ({}, {}): {}", self.meta.name, self.meta.category.as_deref().unwrap_or("general"), self.meta.trust, self.meta.description ) } } // ─── SkillsManager ─────────────────────────────────────────────────────── /// Central registry for skills discovery, loading, and indexing. /// /// Scans one or more filesystem directories for skill subdirectories /// (containing a `SKILL.md` file), parses YAML frontmatter, and maintains /// a cache of loaded skills. pub struct SkillsManager { /// Directories to scan for skills. skill_dirs: Vec, /// Loaded skills keyed by name. skills: HashMap, /// Whether to automatically load skills on scan. auto_load: bool, } impl SkillsManager { /// Create a new empty skills manager. pub fn new() -> Self { Self { skill_dirs: Vec::new(), skills: HashMap::new(), auto_load: true, } } /// Register a directory to scan for skills. pub fn with_skill_dir(mut self, dir: PathBuf) -> Self { self.skill_dirs.push(dir); self } /// Set whether skills are auto-loaded during scan. pub fn with_auto_load(mut self, auto: bool) -> Self { self.auto_load = auto; self } /// Add multiple skill directories at once. pub fn add_dirs(&mut self, dirs: &[PathBuf]) -> &mut Self { self.skill_dirs.extend_from_slice(dirs); self } /// Get the list of registered skill directories. pub fn skill_dirs(&self) -> &[PathBuf] { &self.skill_dirs } /// Scan all registered directories for skills. /// /// Returns a list of [`SkillMeta`] for discovered skills. If `auto_load` /// is true, each discovered skill is also loaded into the cache. pub fn scan(&mut self) -> Vec { let mut metas = Vec::new(); for dir in &self.skill_dirs { if !dir.exists() { debug!(path = %dir.display(), "Skill directory does not exist, skipping"); continue; } if !dir.is_dir() { debug!(path = %dir.display(), "Skill path is not a directory, skipping"); continue; } info!(path = %dir.display(), "Scanning for skills"); let entries = match std::fs::read_dir(dir) { Ok(e) => e, Err(e) => { warn!(path = %dir.display(), error = %e, "Failed to read skill directory"); continue; } }; let category = dir .file_name() .and_then(|n| n.to_str()) .map(String::from); for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let skill_md = path.join("SKILL.md"); if !skill_md.exists() { continue; } let name = path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); // Parse the SKILL.md to extract metadata let content = match std::fs::read_to_string(&skill_md) { Ok(c) => c, Err(e) => { warn!(skill = %name, error = %e, "Failed to read SKILL.md"); continue; } }; let (meta, body) = Self::parse_skill_file(&name, &content, &category, &path); if self.auto_load { let skill = Skill { meta: meta.clone(), content: body, templates: HashMap::new(), }; self.skills.insert(name.clone(), skill); } metas.push(meta); } } if metas.is_empty() { debug!("No skills discovered in any registered directory"); } else { info!("Discovered {} skill(s)", metas.len()); for m in &metas { debug!(skill = %m.name, category = ?m.category, trust = %m.trust, "Skill discovered"); } } metas } /// Load a specific skill by name, searching registered directories. /// /// Returns `None` if the skill is not found in any directory. /// If already loaded, returns the cached version. pub fn load(&mut self, name: &str) -> Option<&Skill> { // Return from cache if already loaded if self.skills.contains_key(name) { return self.skills.get(name); } // Search directories for matching skill for dir in &self.skill_dirs { let candidate_dir = dir.join(name); let candidate_path = candidate_dir.join("SKILL.md"); if candidate_path.exists() { let content = match std::fs::read_to_string(&candidate_path) { Ok(c) => c, Err(e) => { warn!(skill = %name, path = %candidate_path.display(), error = %e, "Failed to read SKILL.md"); continue; } }; let category = dir .file_name() .and_then(|n| n.to_str()) .map(String::from); let (meta, body) = Self::parse_skill_file(name, &content, &category, &candidate_path); let skill = Skill { meta, content: body, templates: HashMap::new(), }; self.skills.insert(name.to_string(), skill); info!(skill = %name, "Loaded skill"); return self.skills.get(name); } } warn!(skill = %name, "Skill not found in any registered directory"); None } /// Load a skill directly from a specific path (not requiring pre-registered directories). pub fn load_from(&mut self, name: &str, path: &std::path::Path) -> Option<&Skill> { if self.skills.contains_key(name) { return self.skills.get(name); } let skill_md = if path.is_dir() { path.join("SKILL.md") } else { path.to_path_buf() }; if !skill_md.exists() { warn!(skill = %name, path = %skill_md.display(), "SKILL.md not found"); return None; } let content = match std::fs::read_to_string(&skill_md) { Ok(c) => c, Err(e) => { warn!(skill = %name, error = %e, "Failed to read SKILL.md"); return None; } }; let (meta, body) = Self::parse_skill_file(name, &content, &None, &skill_md); let skill = Skill { meta, content: body, templates: HashMap::new(), }; self.skills.insert(name.to_string(), skill); info!(skill = %name, path = %skill_md.display(), "Loaded skill from path"); self.skills.get(name) } /// Get a reference to a loaded skill by name (without loading if not cached). pub fn get(&self, name: &str) -> Option<&Skill> { self.skills.get(name) } /// Check if a skill is already loaded. pub fn is_loaded(&self, name: &str) -> bool { self.skills.contains_key(name) } /// Return a list of all loaded skill metadata. pub fn list_skills(&self) -> Vec { self.skills.values().map(|s| s.meta.clone()).collect() } /// Return the number of loaded skills. pub fn len(&self) -> usize { self.skills.len() } /// True if no skills are loaded. pub fn is_empty(&self) -> bool { self.skills.is_empty() } /// Remove a loaded skill from cache. pub fn unload(&mut self, name: &str) -> Option { self.skills.remove(name) } /// Clear all loaded skills (cache only, not filesystem). pub fn clear(&mut self) { self.skills.clear(); } /// Build a compact skill index for system prompt injection. /// /// Produces a human-readable listing of available skills with name, /// category, and description — suitable for embedding in the LLM's /// system prompt so it can invoke `skill_view(name)`. pub fn inject_skills(&self) -> String { let mut lines = Vec::new(); if self.skills.is_empty() { lines.push("(no skills loaded)".to_string()); return lines.join("\n"); } lines.push("Available skills (load with skill_view for full content):".to_string()); // Group by category for readability let mut by_category: HashMap<&str, Vec<&Skill>> = HashMap::new(); for skill in self.skills.values() { let cat = skill .meta .category .as_deref() .unwrap_or("uncategorized"); by_category.entry(cat).or_default().push(skill); } let mut categories: Vec<&&str> = by_category.keys().collect(); categories.sort(); for cat in categories { let skills = &by_category[*cat]; lines.push(format!(" [{}]", cat)); for skill in skills { lines.push(format!( " - {} (trust: {}): {}", skill.meta.name, skill.meta.trust, skill.meta.description )); } } lines.join("\n") } // ── Internal helpers ──────────────────────────────────────────────── /// Parse YAML frontmatter from a SKILL.md file. /// /// Frontmatter format: /// ```markdown /// --- /// name: my-skill /// description: Does something useful /// tags: [tag1, tag2] /// trust: trusted /// --- /// Skill content starts here... /// ``` fn parse_skill_file( name: &str, content: &str, category: &Option, path: &std::path::Path, ) -> (SkillMeta, String) { let mut meta = SkillMeta::new(name, ""); meta.category = category.clone(); meta.path = path.to_path_buf(); // Try to extract YAML frontmatter between --- markers let body = if let Some(end_of_fm) = Self::extract_frontmatter(content, &mut meta) { content[end_of_fm..].trim().to_string() } else { // No frontmatter: use first line as description let first_line = content.lines().next().unwrap_or("").to_string(); meta.description = first_line; content.to_string() }; (meta, body) } /// Extract YAML-like frontmatter from markdown content. /// /// Returns the byte offset where the body begins (after the closing `---`). /// Modifies `meta` in place with parsed fields. fn extract_frontmatter(content: &str, meta: &mut SkillMeta) -> Option { let content_bytes = content.as_bytes(); // Must start with "---\n" or "---\r\n" if content_bytes.len() < 5 { return None; } if !content.starts_with("---") { return None; } // Find the closing "---" marker let search_start = 3; // skip opening --- let remainder = &content[search_start..]; // The opening --- might be "---\n" or "---\r\n" let opening_newline = if content_bytes.get(3) == Some(&b'\r') && content_bytes.get(4) == Some(&b'\n') { search_start + 2 } else if content_bytes.get(3) == Some(&b'\n') { search_start + 1 } else { return None; // malformed opening }; // Search for closing "---" after the opening line if let Some(closing_pos) = remainder[1..].find("\n---") { let closing_start = search_start + 1 + closing_pos; // Could be "\n---\n" or "\n---\r\n" let closing_end = if content_bytes.get(closing_start + 4) == Some(&b'\n') { closing_start + 5 } else if content_bytes.get(closing_start + 4) == Some(&b'\r') && content_bytes.get(closing_start + 5) == Some(&b'\n') { closing_start + 6 } else { // Just "---" at end of content closing_start + 4 }; let fm_text = &content[opening_newline..closing_start]; // Parse simple key: value lines from frontmatter for line in fm_text.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some((key, value)) = line.split_once(':') { let key = key.trim().to_lowercase(); let value = value.trim().trim_matches('"').trim_matches('\''); match key.as_str() { "name" => { if !value.is_empty() { meta.name = value.to_string(); } } "description" => { meta.description = value.to_string(); } "category" => { if meta.category.is_none() && !value.is_empty() { meta.category = Some(value.to_string()); } } "tags" => { // Parse [tag1, tag2, ...] or comma-separated let tags_str = value .trim_start_matches('[') .trim_end_matches(']'); for tag in tags_str.split(',') { let t = tag.trim().trim_matches('"').trim_matches('\''); if !t.is_empty() { meta.tags.push(t.to_string()); } } } "trust" => { meta.trust = TrustLevel::from_str(value); } "version" => { meta.version = Some(value.to_string()); } "author" => { meta.author = Some(value.to_string()); } _ => { debug!(key = %key, value = %value, "Unknown frontmatter field"); } } } } Some(closing_end) } else { None } } } impl Default for SkillsManager { fn default() -> Self { Self::new() } } // ─── Preprocessor ──────────────────────────────────────────────────────── /// Processes skill content by resolving template placeholders. /// /// Supports `${VAR}` syntax with recursive resolution up to a configurable /// depth limit to prevent infinite loops. pub struct Preprocessor { /// Maximum recursion depth for nested variable resolution. max_depth: usize, } impl Preprocessor { /// Create a new preprocessor with default max depth (5). pub fn new() -> Self { Self { max_depth: 5 } } /// Create a preprocessor with a custom max recursion depth. pub fn with_max_depth(max_depth: usize) -> Self { Self { max_depth } } /// Resolve `${VAR}` placeholders in skill content. /// /// Variables are replaced with values from the provided map. /// If a variable references another `${VAR}` inside its value, it /// is resolved recursively up to `max_depth` levels. /// /// Returns the resolved string. Unresolved placeholders are left as-is. pub fn process(&self, content: &str, vars: &HashMap) -> String { let mut result = content.to_string(); let mut depth = 0; loop { if depth >= self.max_depth { warn!("Preprocessor reached max recursion depth ({})", self.max_depth); break; } let before = result.len(); for (key, value) in vars { // Resolve values recursively let resolved_value = self.resolve_value(value, vars, depth); result = result.replace(&format!("${{{}}}", key), &resolved_value); } // Check if any replacements were made if result.len() == before { break; } // Check if there are still unresolved ${...} patterns if !result.contains("${") { break; } depth += 1; } result } /// Recursively resolve a single variable value. fn resolve_value(&self, value: &str, vars: &HashMap, depth: usize) -> String { if depth >= self.max_depth || !value.contains("${") { return value.to_string(); } let mut result = value.to_string(); for (key, val) in vars { result = result.replace(&format!("${{{}}}", key), val); } if result.contains("${") { self.resolve_value(&result, vars, depth + 1) } else { result } } /// Extract all `${VAR}` placeholder names from content (without resolving). pub fn extract_variables(&self, content: &str) -> Vec { let mut vars = Vec::new(); let mut remaining = content; while let Some(start) = remaining.find("${") { let after_start = &remaining[start + 2..]; if let Some(end) = after_start.find('}') { let var_name = after_start[..end].to_string(); if !vars.contains(&var_name) { vars.push(var_name); } remaining = &after_start[end + 1..]; } else { break; } } vars } } impl Default for Preprocessor { fn default() -> Self { Self::new() } } // ─── SkillBundle ───────────────────────────────────────────────────────── /// An archive of multiple skills, typically distributed as a single file. /// /// Bundles allow skills to be packaged together and loaded in one operation. /// The format is a JSON or YAML array of skill records. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillBundle { /// Manifest version for forward compatibility. pub version: String, /// Bundle name. pub name: String, /// Human-readable description of the bundle. pub description: String, /// Skills contained in the bundle. pub skills: Vec, } /// A single skill record within a bundle. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BundleSkill { /// Skill name. pub name: String, /// One-line description. pub description: String, /// Optional category. pub category: Option, /// Tags for discovery. pub tags: Vec, /// Trust level. pub trust: Option, /// Markdown content (may include frontmatter). pub content: String, } impl SkillBundle { /// Load a bundle from a JSON string. pub fn from_json(json: &str) -> Result { serde_json::from_str(json).map_err(|e| anyhow::anyhow!("Failed to parse skill bundle JSON: {e}")) } /// Load a bundle from a JSON file. pub fn from_file(path: &std::path::Path) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| anyhow::anyhow!("Failed to read bundle file '{}': {e}", path.display()))?; Self::from_json(&content) } /// Install all skills from this bundle into a [`SkillsManager`]. /// /// Returns the number of skills installed. pub fn install_into(&self, manager: &mut SkillsManager) -> usize { let mut count = 0; for bundle_skill in &self.skills { let meta = SkillMeta { name: bundle_skill.name.clone(), description: bundle_skill.description.clone(), category: bundle_skill.category.clone(), tags: bundle_skill.tags.clone(), trust: bundle_skill.trust.clone().unwrap_or(TrustLevel::Community), path: PathBuf::new(), version: None, author: None, }; let skill = Skill { meta, content: bundle_skill.content.clone(), templates: HashMap::new(), }; manager.skills.insert(bundle_skill.name.clone(), skill); count += 1; } info!("Installed {} skills from bundle '{}'", count, self.name); count } /// Serialize this bundle to pretty-printed JSON. pub fn to_json_pretty(&self) -> Result { serde_json::to_string_pretty(self) .map_err(|e| anyhow::anyhow!("Failed to serialize bundle: {e}")) } /// Write this bundle to a JSON file. pub fn write_to(&self, path: &std::path::Path) -> Result<(), anyhow::Error> { let json = self.to_json_pretty()?; std::fs::write(path, &json) .map_err(|e| anyhow::anyhow!("Failed to write bundle to '{}': {e}", path.display()))?; Ok(()) } /// Create a bundle from a list of loaded skills in a manager. pub fn from_manager( name: &str, description: &str, skill_names: &[&str], manager: &SkillsManager, ) -> Result { let mut skills = Vec::new(); for sname in skill_names { let skill = manager .get(sname) .ok_or_else(|| anyhow::anyhow!("Skill '{sname}' not found in manager"))?; skills.push(BundleSkill { name: skill.meta.name.clone(), description: skill.meta.description.clone(), category: skill.meta.category.clone(), tags: skill.meta.tags.clone(), trust: Some(skill.meta.trust.clone()), content: skill.content.clone(), }); } Ok(Self { version: "1.0".to_string(), name: name.to_string(), description: description.to_string(), skills, }) } } // ─── SkillHub ──────────────────────────────────────────────────────────── /// Configuration for connecting to a remote skill hub. /// /// A skill hub is an HTTP server that serves skill metadata and content. /// This is an optional feature enabled by the `hub` feature flag. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillHubConfig { /// Base URL of the hub (e.g. "https://hub.gyandoot.dev"). pub base_url: String, /// Optional API token for private hubs. pub api_token: Option, /// Timeout in seconds for hub requests. pub timeout_secs: u64, } impl Default for SkillHubConfig { fn default() -> Self { Self { base_url: "https://hub.gyandoot.dev".to_string(), api_token: None, timeout_secs: 10, } } } // ─── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use std::fs; fn create_test_skill_dir() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); let skill_dir = dir.path().join("test-skill"); fs::create_dir_all(&skill_dir).unwrap(); let content = r#"--- name: test-skill description: A test skill for unit testing tags: [test, example] trust: trusted author: GyanDoot Team --- # Test Skill This is a test skill used for unit testing. "#; fs::write(skill_dir.join("SKILL.md"), content).unwrap(); dir } #[test] fn test_preprocessor_variable_resolution() { let pp = Preprocessor::new(); let mut vars = HashMap::new(); vars.insert("NAME".to_string(), "Maya".to_string()); let result = pp.process("Hello ${NAME}!", &vars); assert_eq!(result, "Hello Maya!"); } #[test] fn test_preprocessor_multiple_variables() { let pp = Preprocessor::new(); let mut vars = HashMap::new(); vars.insert("NAME".to_string(), "Alice".to_string()); vars.insert("AGE".to_string(), "30".to_string()); let result = pp.process("${NAME} is ${AGE} years old", &vars); assert_eq!(result, "Alice is 30 years old"); } #[test] fn test_preprocessor_unknown_variable() { let pp = Preprocessor::new(); let vars = HashMap::new(); let result = pp.process("Hello ${UNKNOWN}!", &vars); // Unknown variables should remain as-is assert_eq!(result, "Hello ${UNKNOWN}!"); } #[test] fn test_preprocessor_recursive_resolution() { let pp = Preprocessor::new(); let mut vars = HashMap::new(); vars.insert("NAME".to_string(), "Bob".to_string()); vars.insert("GREETING".to_string(), "Hello ${NAME}".to_string()); let result = pp.process("${GREETING}!", &vars); assert_eq!(result, "Hello Bob!"); } #[test] fn test_preprocessor_no_substitution() { let pp = Preprocessor::new(); let vars = HashMap::new(); let result = pp.process("No variables here", &vars); assert_eq!(result, "No variables here"); } #[test] fn test_preprocessor_extract_variables() { let pp = Preprocessor::new(); let vars = pp.extract_variables("Hello ${NAME}, you are ${AGE} years old"); assert_eq!(vars.len(), 2); assert!(vars.contains(&"NAME".to_string())); assert!(vars.contains(&"AGE".to_string())); } #[test] fn test_skills_manager_initialization() { let sm = SkillsManager::new(); assert!(sm.is_empty()); assert_eq!(sm.len(), 0); } #[test] fn test_skills_manager_scan_discovery() { let tmp_dir = create_test_skill_dir(); let mut sm = SkillsManager::new() .with_skill_dir(tmp_dir.path().to_path_buf()); let metas = sm.scan(); assert_eq!(metas.len(), 1); assert_eq!(metas[0].name, "test-skill"); assert_eq!(metas[0].description, "A test skill for unit testing"); assert_eq!(metas[0].trust, TrustLevel::Trusted); assert_eq!(metas[0].tags, vec!["test", "example"]); assert_eq!(metas[0].author.as_deref(), Some("GyanDoot Team")); } #[test] fn test_skills_manager_load() { let tmp_dir = create_test_skill_dir(); let mut sm = SkillsManager::new() .with_skill_dir(tmp_dir.path().to_path_buf()); sm.scan(); let skill = sm.load("test-skill"); assert!(skill.is_some()); assert_eq!(skill.unwrap().meta.name, "test-skill"); } #[test] fn test_skills_manager_load_nonexistent() { let mut sm = SkillsManager::new(); let skill = sm.load("nonexistent-skill"); assert!(skill.is_none()); } #[test] fn test_skills_manager_load_from_path() { let tmp_dir = create_test_skill_dir(); let mut sm = SkillsManager::new(); let skill = sm.load_from("test-skill", &tmp_dir.path().join("test-skill")); assert!(skill.is_some()); assert_eq!(skill.unwrap().meta.name, "test-skill"); } #[test] fn test_skills_manager_inject_empty() { let sm = SkillsManager::new(); let index = sm.inject_skills(); assert!(index.contains("no skills loaded")); } #[test] fn test_skills_manager_inject_with_skills() { let tmp_dir = create_test_skill_dir(); let mut sm = SkillsManager::new() .with_skill_dir(tmp_dir.path().to_path_buf()); sm.scan(); let index = sm.inject_skills(); assert!(index.contains("test-skill")); assert!(index.contains("A test skill for unit testing")); } #[test] fn test_skills_manager_unload() { let tmp_dir = create_test_skill_dir(); let mut sm = SkillsManager::new() .with_skill_dir(tmp_dir.path().to_path_buf()); sm.scan(); assert_eq!(sm.len(), 1); let unloaded = sm.unload("test-skill"); assert!(unloaded.is_some()); assert_eq!(sm.len(), 0); } #[test] fn test_skills_manager_clear() { let tmp_dir = create_test_skill_dir(); let mut sm = SkillsManager::new() .with_skill_dir(tmp_dir.path().to_path_buf()); sm.scan(); assert_eq!(sm.len(), 1); sm.clear(); assert_eq!(sm.len(), 0); } #[test] fn test_skill_meta_defaults() { let meta = SkillMeta::new("test", "A test"); assert_eq!(meta.name, "test"); assert_eq!(meta.description, "A test"); assert!(meta.category.is_none()); assert!(meta.tags.is_empty()); assert_eq!(meta.trust, TrustLevel::Unverified); } #[test] fn test_skill_meta_builder() { let meta = SkillMeta::new("code-review", "Reviews code") .with_category("dev") .with_tag("code") .with_tag("review") .with_trust(TrustLevel::Trusted) .with_path(PathBuf::from("/skills/code-review")); assert_eq!(meta.category.as_deref(), Some("dev")); assert_eq!(meta.tags.len(), 2); assert_eq!(meta.trust, TrustLevel::Trusted); } #[test] fn test_trust_level_roundtrip() { for level in &[ TrustLevel::Builtin, TrustLevel::Trusted, TrustLevel::Community, TrustLevel::Unverified, ] { let s = level.to_string(); let parsed = TrustLevel::from_str(&s); assert_eq!(*level, parsed); } } #[test] fn test_trust_level_unknown_defaults_to_unverified() { assert_eq!(TrustLevel::from_str("unknown"), TrustLevel::Unverified); } #[test] fn test_skill_summary() { let meta = SkillMeta::new("git-helper", "Git operations helper") .with_category("dev") .with_trust(TrustLevel::Builtin); let skill = Skill::new(meta, "content"); let summary = skill.summary(); assert!(summary.contains("git-helper")); assert!(summary.contains("dev")); assert!(summary.contains("builtin")); } #[test] fn test_frontmatter_parsing_basic() { let content = r#"--- name: my-skill description: My skill description tags: [tag1, tag2] trust: trusted --- # Skill Body"#; let mut meta = SkillMeta::new("my-skill", ""); let body = SkillsManager::extract_frontmatter(content, &mut meta); assert!(body.is_some()); assert_eq!(meta.description, "My skill description"); assert_eq!(meta.tags, vec!["tag1", "tag2"]); assert_eq!(meta.trust, TrustLevel::Trusted); } #[test] fn test_frontmatter_parsing_no_frontmatter() { let mut meta = SkillMeta::new("test", ""); let body = SkillsManager::extract_frontmatter("Just content, no frontmatter", &mut meta); assert!(body.is_none()); } #[test] fn test_frontmatter_parsing_empty() { let mut meta = SkillMeta::new("test", ""); let body = SkillsManager::extract_frontmatter("", &mut meta); assert!(body.is_none()); } #[test] fn test_skill_bundle_roundtrip() { let bundle = SkillBundle { version: "1.0".to_string(), name: "test-bundle".to_string(), description: "A test bundle".to_string(), skills: vec![ BundleSkill { name: "skill-one".to_string(), description: "First skill".to_string(), category: Some("dev".to_string()), tags: vec!["rust".to_string()], trust: Some(TrustLevel::Trusted), content: "# Skill One\nContent".to_string(), }, BundleSkill { name: "skill-two".to_string(), description: "Second skill".to_string(), category: None, tags: Vec::new(), trust: None, content: "# Skill Two\nContent".to_string(), }, ], }; let json = bundle.to_json_pretty().unwrap(); let parsed = SkillBundle::from_json(&json).unwrap(); assert_eq!(parsed.name, "test-bundle"); assert_eq!(parsed.skills.len(), 2); assert_eq!(parsed.skills[0].name, "skill-one"); assert_eq!(parsed.skills[1].name, "skill-two"); } #[test] fn test_skill_bundle_install_into_manager() { let bundle = SkillBundle { version: "1.0".to_string(), name: "test-bundle".to_string(), description: "Test".to_string(), skills: vec![BundleSkill { name: "bundled-skill".to_string(), description: "From bundle".to_string(), category: Some("bundles".to_string()), tags: vec!["bundle".to_string()], trust: Some(TrustLevel::Community), content: "# Bundled Skill\nContent".to_string(), }], }; let mut manager = SkillsManager::new(); let count = bundle.install_into(&mut manager); assert_eq!(count, 1); assert!(manager.is_loaded("bundled-skill")); assert_eq!(manager.len(), 1); } #[test] fn test_skill_bundle_from_manager() { // Setup: create a skill in the manager let mut manager = SkillsManager::new(); let meta = SkillMeta::new("my-skill", "A skill") .with_category("dev") .with_trust(TrustLevel::Builtin) .with_tag("important"); let skill = Skill::new(meta, "# Content"); manager.unload("my-skill"); // no-op if not loaded // Direct insert manager.load_from("my-skill", &PathBuf::from("/tmp/nonexistent")).ok_or(()).unwrap_err(); // Use bundle from manager // First directly insert into the skills map via a workaround let meta2 = SkillMeta::new("direct-skill", "Direct") .with_category("test") .with_trust(TrustLevel::Trusted); let skill2 = Skill::new(meta2, "# Direct content"); manager.skills.insert("direct-skill".to_string(), skill2); let bundle = SkillBundle::from_manager( "export-bundle", "Exported skills", &["direct-skill"], &manager, ) .unwrap(); assert_eq!(bundle.skills.len(), 1); assert_eq!(bundle.skills[0].name, "direct-skill"); // Re-import into a fresh manager let mut fresh_manager = SkillsManager::new(); bundle.install_into(&mut fresh_manager); assert!(fresh_manager.is_loaded("direct-skill")); } #[test] fn test_skills_manager_missing_directory() { let mut sm = SkillsManager::new() .with_skill_dir(PathBuf::from("/tmp/gyandoot-test-nonexistent-dir")); let metas = sm.scan(); assert!(metas.is_empty()); } #[test] fn test_skills_manager_load_from_nonexistent_path() { let mut sm = SkillsManager::new(); let skill = sm.load_from("nope", &PathBuf::from("/tmp/no-such-skill")); assert!(skill.is_none()); } #[test] fn test_preprocessor_max_depth_limit() { let pp = Preprocessor::with_max_depth(2); let mut vars = HashMap::new(); vars.insert("A".to_string(), "${B}".to_string()); vars.insert("B".to_string(), "${C}".to_string()); vars.insert("C".to_string(), "deep_value".to_string()); // With depth 2, should stop before fully resolving let result = pp.process("${A}", &vars); // Should contain at least ${B} or ${C} unresolved assert!(result.contains("${")); // incomplete resolution } #[test] fn test_skill_hub_config_default() { let cfg = SkillHubConfig::default(); assert_eq!(cfg.base_url, "https://hub.gyandoot.dev"); assert!(cfg.api_token.is_none()); assert_eq!(cfg.timeout_secs, 10); } #[test] fn test_skill_bundle_write_and_read() { let bundle = SkillBundle { version: "1.0".to_string(), name: "rw-test".to_string(), description: "Write/read test".to_string(), skills: vec![BundleSkill { name: "rw-skill".to_string(), description: "Test".to_string(), category: None, tags: Vec::new(), trust: None, content: "# Test".to_string(), }], }; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("bundle.json"); bundle.write_to(&path).unwrap(); let loaded = SkillBundle::from_file(&path).unwrap(); assert_eq!(loaded.name, "rw-test"); assert_eq!(loaded.skills.len(), 1); } }