use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SkillsError {
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
#[error("YAML parsing error: {0}")]
YamlError(String),
#[error("Validation error: {0}")]
ValidationError(String),
#[error("Path traversal security violation: {0}")]
PathTraversalError(String),
}
impl From<serde_yaml::Error> for SkillsError {
fn from(err: serde_yaml::Error) -> Self {
SkillsError::YamlError(err.to_string())
}
}
#[derive(Debug, Clone)]
pub struct SkillsCopyResult {
pub files_copied: usize,
pub files_skipped: usize,
pub warnings: Vec<String>,
pub destination_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct SkillsValidation {
pub is_valid: bool,
pub missing_required: Vec<String>,
pub missing_optional: Vec<String>,
pub total_rule_files: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SkillsIndex {
pub skills_by_tag: std::collections::HashMap<String, Vec<String>>,
pub total_skills: usize,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SkillFile {
pub path: String,
pub title: String,
pub tags: Vec<String>,
pub has_frontmatter: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[test]
fn test_io_error_conversion() {
let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
let skills_error: SkillsError = io_error.into();
match skills_error {
SkillsError::IoError(_) => (),
_ => panic!("Expected IoError variant"),
}
}
#[test]
fn test_yaml_error_display() {
let error = SkillsError::YamlError("Invalid YAML syntax".to_string());
let display = format!("{}", error);
assert!(display.contains("YAML parsing error"));
assert!(display.contains("Invalid YAML syntax"));
}
#[test]
fn test_validation_error_display() {
let error = SkillsError::ValidationError("Missing SKILL.md".to_string());
let display = format!("{}", error);
assert!(display.contains("Validation error"));
assert!(display.contains("Missing SKILL.md"));
}
#[test]
fn test_path_traversal_error_display() {
let error = SkillsError::PathTraversalError(
"Path contains '..' components".to_string()
);
let display = format!("{}", error);
assert!(display.contains("Path traversal"));
assert!(display.contains("'..'"));
}
#[test]
fn test_serde_yaml_error_conversion() {
let invalid_yaml = "invalid: yaml: syntax:";
let yaml_error = serde_yaml::from_str::<serde_yaml::Value>(invalid_yaml)
.unwrap_err();
let skills_error: SkillsError = yaml_error.into();
match skills_error {
SkillsError::YamlError(_) => (),
_ => panic!("Expected YamlError variant"),
}
}
#[test]
fn test_skills_copy_result_creation() {
let result = SkillsCopyResult {
files_copied: 31,
files_skipped: 0,
warnings: vec!["Optional file missing".to_string()],
destination_path: PathBuf::from("/workspace/.kiro/steering/composio"),
};
assert_eq!(result.files_copied, 31);
assert_eq!(result.files_skipped, 0);
assert_eq!(result.warnings.len(), 1);
}
#[test]
fn test_skills_validation_creation() {
let validation = SkillsValidation {
is_valid: true,
missing_required: vec![],
missing_optional: vec!["optional.md".to_string()],
total_rule_files: 29,
};
assert!(validation.is_valid);
assert_eq!(validation.missing_required.len(), 0);
assert_eq!(validation.missing_optional.len(), 1);
assert_eq!(validation.total_rule_files, 29);
}
#[test]
fn test_error_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<SkillsError>();
}
#[test]
fn test_skills_copy_result_is_clone() {
let result = SkillsCopyResult {
files_copied: 10,
files_skipped: 5,
warnings: vec![],
destination_path: PathBuf::from("/test"),
};
let cloned = result.clone();
assert_eq!(cloned.files_copied, result.files_copied);
assert_eq!(cloned.files_skipped, result.files_skipped);
}
#[test]
fn test_skills_validation_is_clone() {
let validation = SkillsValidation {
is_valid: false,
missing_required: vec!["SKILL.md".to_string()],
missing_optional: vec![],
total_rule_files: 0,
};
let cloned = validation.clone();
assert_eq!(cloned.is_valid, validation.is_valid);
assert_eq!(cloned.missing_required, validation.missing_required);
}
#[test]
fn test_skills_index_creation() {
use std::collections::HashMap;
let mut skills_by_tag = HashMap::new();
skills_by_tag.insert(
"tool-router".to_string(),
vec!["rules/tr-userid-best-practices.md".to_string()],
);
skills_by_tag.insert(
"security".to_string(),
vec![
"rules/tr-userid-best-practices.md".to_string(),
"rules/tr-auth-auto.md".to_string(),
],
);
let index = SkillsIndex {
skills_by_tag,
total_skills: 29,
};
assert_eq!(index.total_skills, 29);
assert_eq!(index.skills_by_tag.len(), 2);
assert_eq!(index.skills_by_tag.get("security").unwrap().len(), 2);
}
#[test]
fn test_skills_index_serialization() {
use std::collections::HashMap;
let mut skills_by_tag = HashMap::new();
skills_by_tag.insert(
"test-tag".to_string(),
vec!["test-file.md".to_string()],
);
let index = SkillsIndex {
skills_by_tag,
total_skills: 1,
};
let json = serde_json::to_string(&index).unwrap();
assert!(json.contains("test-tag"));
assert!(json.contains("test-file.md"));
let deserialized: SkillsIndex = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.total_skills, 1);
assert_eq!(deserialized.skills_by_tag.len(), 1);
}
#[test]
fn test_skill_file_creation() {
let skill_file = SkillFile {
path: "rules/tr-userid-best-practices.md".to_string(),
title: "Choose User IDs Carefully for Security and Isolation".to_string(),
tags: vec![
"tool-router".to_string(),
"user-id".to_string(),
"security".to_string(),
],
has_frontmatter: true,
};
assert_eq!(skill_file.path, "rules/tr-userid-best-practices.md");
assert_eq!(skill_file.tags.len(), 3);
assert!(skill_file.has_frontmatter);
}
#[test]
fn test_skill_file_serialization() {
let skill_file = SkillFile {
path: "test.md".to_string(),
title: "Test Skill".to_string(),
tags: vec!["test".to_string()],
has_frontmatter: true,
};
let json = serde_json::to_string(&skill_file).unwrap();
assert!(json.contains("test.md"));
assert!(json.contains("Test Skill"));
let deserialized: SkillFile = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.path, "test.md");
assert_eq!(deserialized.title, "Test Skill");
assert_eq!(deserialized.tags.len(), 1);
}
#[test]
fn test_skills_index_is_clone() {
use std::collections::HashMap;
let mut skills_by_tag = HashMap::new();
skills_by_tag.insert("tag1".to_string(), vec!["file1.md".to_string()]);
let index = SkillsIndex {
skills_by_tag,
total_skills: 1,
};
let cloned = index.clone();
assert_eq!(cloned.total_skills, index.total_skills);
assert_eq!(cloned.skills_by_tag.len(), index.skills_by_tag.len());
}
#[test]
fn test_skill_file_is_clone() {
let skill_file = SkillFile {
path: "test.md".to_string(),
title: "Test".to_string(),
tags: vec!["tag1".to_string()],
has_frontmatter: false,
};
let cloned = skill_file.clone();
assert_eq!(cloned.path, skill_file.path);
assert_eq!(cloned.title, skill_file.title);
assert_eq!(cloned.has_frontmatter, skill_file.has_frontmatter);
}
}
pub async fn validate_skills_structure(
vendor_dir: &std::path::Path,
) -> Result<SkillsValidation, SkillsError> {
use tokio::fs;
if !vendor_dir.exists() {
return Ok(SkillsValidation {
is_valid: false,
missing_required: vec![
format!("Source directory not found: {}", vendor_dir.display())
],
missing_optional: vec![],
total_rule_files: 0,
});
}
let metadata = fs::metadata(vendor_dir).await?;
if !metadata.is_dir() {
return Ok(SkillsValidation {
is_valid: false,
missing_required: vec![
format!("Path is not a directory: {}", vendor_dir.display())
],
missing_optional: vec![],
total_rule_files: 0,
});
}
let mut missing_required = Vec::new();
let mut missing_optional = Vec::new();
let skill_md = vendor_dir.join("SKILL.md");
if !skill_md.exists() {
missing_required.push("SKILL.md".to_string());
}
let agents_md = vendor_dir.join("AGENTS.md");
if !agents_md.exists() {
missing_required.push("AGENTS.md".to_string());
}
let rules_dir = vendor_dir.join("rules");
let mut total_rule_files = 0;
if !rules_dir.exists() {
missing_required.push("rules/ directory".to_string());
} else {
let rules_metadata = fs::metadata(&rules_dir).await?;
if !rules_metadata.is_dir() {
missing_required.push("rules/ is not a directory".to_string());
} else {
let mut entries = fs::read_dir(&rules_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_file() {
if let Some(extension) = path.extension() {
if extension == "md" {
total_rule_files += 1;
}
}
}
}
if total_rule_files == 0 {
missing_optional.push(
"No markdown files found in rules/ directory".to_string()
);
}
}
}
let is_valid = missing_required.is_empty();
Ok(SkillsValidation {
is_valid,
missing_required,
missing_optional,
total_rule_files,
})
}
#[cfg(test)]
mod validation_tests {
use super::*;
use std::path::PathBuf;
use tokio::fs;
async fn create_test_dir() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
Ok(temp_dir)
}
async fn create_test_skills_structure(
base_dir: &std::path::Path,
include_skill_md: bool,
include_agents_md: bool,
include_rules_dir: bool,
num_rule_files: usize,
) -> Result<(), Box<dyn std::error::Error>> {
if include_skill_md {
let skill_path = base_dir.join("SKILL.md");
fs::write(&skill_path, "# Composio Skills\n\nTest content").await?;
}
if include_agents_md {
let agents_path = base_dir.join("AGENTS.md");
fs::write(&agents_path, "# Agents\n\nTest content").await?;
}
if include_rules_dir {
let rules_dir = base_dir.join("rules");
fs::create_dir(&rules_dir).await?;
for i in 0..num_rule_files {
let rule_path = rules_dir.join(format!("rule-{}.md", i));
fs::write(&rule_path, format!("# Rule {}\n\nTest content", i)).await?;
}
}
Ok(())
}
#[tokio::test]
async fn test_validate_skills_structure_with_valid_directory() {
let temp_dir = create_test_dir().await.unwrap();
let vendor_path = temp_dir.path();
create_test_skills_structure(vendor_path, true, true, true, 5)
.await
.unwrap();
let validation = validate_skills_structure(vendor_path).await.unwrap();
assert!(validation.is_valid);
assert_eq!(validation.missing_required.len(), 0);
assert_eq!(validation.total_rule_files, 5);
}
#[tokio::test]
async fn test_validate_skills_structure_missing_skill_md() {
let temp_dir = create_test_dir().await.unwrap();
let vendor_path = temp_dir.path();
create_test_skills_structure(vendor_path, false, true, true, 3)
.await
.unwrap();
let validation = validate_skills_structure(vendor_path).await.unwrap();
assert!(!validation.is_valid);
assert!(validation.missing_required.contains(&"SKILL.md".to_string()));
assert_eq!(validation.total_rule_files, 3);
}
#[tokio::test]
async fn test_validate_skills_structure_missing_agents_md() {
let temp_dir = create_test_dir().await.unwrap();
let vendor_path = temp_dir.path();
create_test_skills_structure(vendor_path, true, false, true, 3)
.await
.unwrap();
let validation = validate_skills_structure(vendor_path).await.unwrap();
assert!(!validation.is_valid);
assert!(validation.missing_required.contains(&"AGENTS.md".to_string()));
}
#[tokio::test]
async fn test_validate_skills_structure_missing_rules_directory() {
let temp_dir = create_test_dir().await.unwrap();
let vendor_path = temp_dir.path();
create_test_skills_structure(vendor_path, true, true, false, 0)
.await
.unwrap();
let validation = validate_skills_structure(vendor_path).await.unwrap();
assert!(!validation.is_valid);
assert!(validation
.missing_required
.iter()
.any(|s| s.contains("rules/")));
assert_eq!(validation.total_rule_files, 0);
}
#[tokio::test]
async fn test_validate_skills_structure_empty_rules_directory() {
let temp_dir = create_test_dir().await.unwrap();
let vendor_path = temp_dir.path();
create_test_skills_structure(vendor_path, true, true, true, 0)
.await
.unwrap();
let validation = validate_skills_structure(vendor_path).await.unwrap();
assert!(validation.is_valid);
assert!(validation
.missing_optional
.iter()
.any(|s| s.contains("No markdown files")));
assert_eq!(validation.total_rule_files, 0);
}
#[tokio::test]
async fn test_validate_skills_structure_nonexistent_directory() {
let nonexistent_path = PathBuf::from("/nonexistent/path/to/skills");
let validation = validate_skills_structure(&nonexistent_path)
.await
.unwrap();
assert!(!validation.is_valid);
assert!(validation
.missing_required
.iter()
.any(|s| s.contains("Source directory not found")));
}
#[tokio::test]
async fn test_validate_skills_structure_path_is_file() {
let temp_dir = create_test_dir().await.unwrap();
let file_path = temp_dir.path().join("not-a-directory.txt");
fs::write(&file_path, "test content").await.unwrap();
let validation = validate_skills_structure(&file_path).await.unwrap();
assert!(!validation.is_valid);
assert!(validation
.missing_required
.iter()
.any(|s| s.contains("not a directory")));
}
#[tokio::test]
async fn test_validate_skills_structure_multiple_missing_files() {
let temp_dir = create_test_dir().await.unwrap();
let vendor_path = temp_dir.path();
create_test_skills_structure(vendor_path, false, false, false, 0)
.await
.unwrap();
let validation = validate_skills_structure(vendor_path).await.unwrap();
assert!(!validation.is_valid);
assert!(validation.missing_required.len() >= 3);
assert!(validation.missing_required.contains(&"SKILL.md".to_string()));
assert!(validation.missing_required.contains(&"AGENTS.md".to_string()));
assert!(validation
.missing_required
.iter()
.any(|s| s.contains("rules/")));
}
#[tokio::test]
async fn test_validate_skills_structure_with_many_rule_files() {
let temp_dir = create_test_dir().await.unwrap();
let vendor_path = temp_dir.path();
create_test_skills_structure(vendor_path, true, true, true, 29)
.await
.unwrap();
let validation = validate_skills_structure(vendor_path).await.unwrap();
assert!(validation.is_valid);
assert_eq!(validation.missing_required.len(), 0);
assert_eq!(validation.total_rule_files, 29);
}
#[tokio::test]
async fn test_validate_skills_structure_rules_is_file_not_directory() {
let temp_dir = create_test_dir().await.unwrap();
let vendor_path = temp_dir.path();
create_test_skills_structure(vendor_path, true, true, false, 0)
.await
.unwrap();
let rules_path = vendor_path.join("rules");
fs::write(&rules_path, "not a directory").await.unwrap();
let validation = validate_skills_structure(vendor_path).await.unwrap();
assert!(!validation.is_valid);
assert!(validation
.missing_required
.iter()
.any(|s| s.contains("rules/") && s.contains("not a directory")));
}
}
pub fn add_auto_inclusion_frontmatter(content: &str) -> Result<String, SkillsError> {
use serde_yaml::Value;
if content.starts_with("---") {
let content_after_first_delimiter = &content[3..];
if let Some(end_pos) = content_after_first_delimiter.find("\n---") {
let frontmatter_yaml = &content_after_first_delimiter[..end_pos];
let body_start = 3 + end_pos + 4; let body = if body_start < content.len() {
&content[body_start..]
} else {
""
};
let mut frontmatter: serde_yaml::Mapping = serde_yaml::from_str(frontmatter_yaml)
.map_err(|e| SkillsError::YamlError(format!("Failed to parse frontmatter: {}", e)))?;
frontmatter.insert(
Value::String("inclusion".to_string()),
Value::String("auto".to_string()),
);
let new_yaml = serde_yaml::to_string(&frontmatter)
.map_err(|e| SkillsError::YamlError(format!("Failed to serialize frontmatter: {}", e)))?;
Ok(format!("---\n{}---{}", new_yaml, body))
} else {
Err(SkillsError::YamlError(
"Malformed frontmatter: missing closing '---' delimiter".to_string()
))
}
} else {
let frontmatter = "---\ninclusion: auto\n---\n\n";
Ok(format!("{}{}", frontmatter, content))
}
}
#[cfg(test)]
mod frontmatter_tests {
use super::*;
#[test]
fn test_add_frontmatter_to_file_without_frontmatter() {
let content = "# My Skill\n\nThis is a skill file.";
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.starts_with("---\n"));
assert!(result.contains("inclusion: auto"));
assert!(result.contains("# My Skill"));
assert!(result.contains("This is a skill file."));
}
#[test]
fn test_add_frontmatter_to_empty_file() {
let content = "";
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.starts_with("---\n"));
assert!(result.contains("inclusion: auto"));
assert!(result.ends_with("---\n\n"));
}
#[test]
fn test_preserve_existing_frontmatter_fields() {
let content = r#"---
title: My Skill
impact: HIGH
description: A test skill
tags:
- test
- example
---
# Content here"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(result.contains("title: My Skill"));
assert!(result.contains("impact: HIGH"));
assert!(result.contains("description: A test skill"));
assert!(result.contains("tags:"));
assert!(result.contains("- test"));
assert!(result.contains("- example"));
assert!(result.contains("# Content here"));
}
#[test]
fn test_update_existing_inclusion_field() {
let content = r#"---
title: My Skill
inclusion: manual
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(!result.contains("inclusion: manual"));
assert!(result.contains("title: My Skill"));
}
#[test]
fn test_handle_frontmatter_with_complex_yaml() {
let content = r#"---
title: Complex Skill
nested:
field1: value1
field2: value2
list:
- item1
- item2
- item3
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(result.contains("title: Complex Skill"));
assert!(result.contains("nested:"));
assert!(result.contains("field1: value1"));
assert!(result.contains("list:"));
assert!(result.contains("- item1"));
}
#[test]
fn test_malformed_frontmatter_missing_closing_delimiter() {
let content = r#"---
title: My Skill
tags:
- test
# Content without closing ---"#;
let result = add_auto_inclusion_frontmatter(content);
assert!(result.is_err());
match result {
Err(SkillsError::YamlError(msg)) => {
assert!(msg.contains("missing closing"));
}
_ => panic!("Expected YamlError"),
}
}
#[test]
fn test_malformed_yaml_syntax() {
let content = r#"---
title: My Skill
invalid: yaml: syntax:
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content);
assert!(result.is_err());
match result {
Err(SkillsError::YamlError(_)) => (),
_ => panic!("Expected YamlError"),
}
}
#[test]
fn test_frontmatter_with_special_characters() {
let content = r#"---
title: "Skill with: special characters"
description: "Contains \"quotes\" and 'apostrophes'"
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(result.contains("title:"));
assert!(result.contains("description:"));
}
#[test]
fn test_frontmatter_with_multiline_strings() {
let content = r#"---
title: My Skill
description: |
This is a multiline
description that spans
multiple lines
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(result.contains("title: My Skill"));
assert!(result.contains("description:"));
}
#[test]
fn test_preserve_body_formatting() {
let content = r#"---
title: My Skill
---
# Heading 1
Some paragraph with **bold** and *italic*.
## Heading 2
- List item 1
- List item 2
```rust
fn example() {
println!("code block");
}
```"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(result.contains("# Heading 1"));
assert!(result.contains("**bold**"));
assert!(result.contains("*italic*"));
assert!(result.contains("## Heading 2"));
assert!(result.contains("- List item 1"));
assert!(result.contains("```rust"));
assert!(result.contains("fn example()"));
}
#[test]
fn test_empty_frontmatter() {
let content = r#"---
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(result.contains("# Content"));
}
#[test]
fn test_frontmatter_with_only_inclusion() {
let content = r#"---
inclusion: manual
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(!result.contains("inclusion: manual"));
}
#[test]
fn test_frontmatter_with_numeric_values() {
let content = r#"---
title: My Skill
priority: 1
version: 2.5
enabled: true
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(result.contains("title: My Skill"));
assert!(result.contains("priority:"));
assert!(result.contains("version:"));
assert!(result.contains("enabled:"));
}
#[test]
fn test_frontmatter_with_null_values() {
let content = r#"---
title: My Skill
optional_field: null
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(result.contains("title: My Skill"));
}
#[test]
fn test_content_starting_with_dashes_but_not_frontmatter() {
let content = "--- This is not frontmatter\n\nJust regular content.";
let result = add_auto_inclusion_frontmatter(content);
assert!(result.is_err());
match result {
Err(SkillsError::YamlError(msg)) => {
assert!(msg.contains("missing closing"));
}
_ => panic!("Expected YamlError"),
}
}
#[test]
fn test_frontmatter_preserves_field_order() {
let content = r#"---
title: My Skill
impact: HIGH
description: Test
tags:
- tag1
---
# Content"#;
let result = add_auto_inclusion_frontmatter(content).unwrap();
assert!(result.contains("inclusion: auto"));
assert!(result.contains("title:"));
assert!(result.contains("impact:"));
assert!(result.contains("description:"));
assert!(result.contains("tags:"));
}
}