Files
gyandoot-v2/crates/gyandoot-skills/src/lib.rs
Tara Menon dd21654555 P1 deep-dive: platform, security, subagents, skills, profile + installer fix
PLATFORM (gyandoot-platform):
- Telegram adapter: Long-polling, sendMessage, sendDocument, chat actions
- Discord adapter: REST polling, Bearer auth, channel discovery
- Webhook adapter: Hyper v1 HTTP server, HMAC-SHA256 verification
- CLI UX: Robot head banner, spinner, confirm prompts, colored output

SECURITY (gyandoot-security):
- CredentialVault: AES-256-GCM encrypted per-key files in ~/.gyandoot/vault/
- Sanitizer: PII redaction (email, phone, API keys, IPs), control char stripping
- ThreatDetector: Prompt injection, jailbreak, exfiltration detection with severity

SUBAGENTS (gyandoot-subagents):
- SubAgentWorker: Isolated agent loop with tool filtering and workdir
- SubAgentManager: Bounded concurrency via tokio::Semaphore
- Full lifecycle tracking and result collection

SKILLS (gyandoot-skills):
- SkillsManager: scan, load, inject_skills with YAML frontmatter parsing
- Preprocessor:  template resolution
- SkillBundle: JSON bundle serialization

PROFILE (gyandoot-profile):
- Profile struct with builder and validation
- ConfigResolver: env > symlink > profile > default chain
- ProfileManager: create, list, switch, delete, CRUD with real filesystem ops

INSTALLER FIX: Fixed camofox-browser directory escaping bug that caused
'could not find Cargo.toml' on macOS. Always CD back to project root.
2026-06-11 21:18:42 +05:30

1309 lines
44 KiB
Rust

//! # 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<String>,
/// Searchable tags.
pub tags: Vec<String>,
/// Trust level classification.
pub trust: TrustLevel,
/// Filesystem path to the skill directory or file.
pub path: PathBuf,
/// Optional version string.
pub version: Option<String>,
/// Optional author string.
pub author: Option<String>,
}
impl SkillMeta {
/// Create a new skill metadata entry.
pub fn new(name: impl Into<String>, description: impl Into<String>) -> 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<String>) -> Self {
self.category = Some(category.into());
self
}
/// Builder: add a tag.
pub fn with_tag(mut self, tag: impl Into<String>) -> 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<String, String>,
}
impl Skill {
/// Create a new skill from its parts.
pub fn new(meta: SkillMeta, content: impl Into<String>) -> 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<PathBuf>,
/// Loaded skills keyed by name.
skills: HashMap<String, Skill>,
/// 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<SkillMeta> {
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<SkillMeta> {
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<Skill> {
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<String>,
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<usize> {
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, String>) -> 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<String, String>, 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<String> {
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<BundleSkill>,
}
/// 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<String>,
/// Tags for discovery.
pub tags: Vec<String>,
/// Trust level.
pub trust: Option<TrustLevel>,
/// Markdown content (may include frontmatter).
pub content: String,
}
impl SkillBundle {
/// Load a bundle from a JSON string.
pub fn from_json(json: &str) -> Result<Self, anyhow::Error> {
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<Self, anyhow::Error> {
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<String, anyhow::Error> {
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<Self, anyhow::Error> {
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<String>,
/// 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);
}
}