Skip to main content

composio_sdk/
skills_integration.rs

1//! Skills Integration Module
2//!
3//! This module provides functionality for integrating Composio Skills from the vendor
4//! directory into the workspace steering directory. It handles:
5//!
6//! - Copying Skills files with frontmatter modification
7//! - Validating Skills directory structure
8//! - Generating reference documentation
9//! - Indexing Skills by tags and impact levels
10//!
11//! # Example
12//!
13//! ```no_run
14//! use composio_sdk::skills_integration::copy_composio_skills;
15//! use std::path::Path;
16//!
17//! #[tokio::main]
18//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
19//!     let workspace_dir = Path::new("/path/to/workspace");
20//!     let result = copy_composio_skills(workspace_dir).await?;
21//!     
22//!     println!("Copied {} files", result.files_copied);
23//!     println!("Skipped {} files", result.files_skipped);
24//!     
25//!     Ok(())
26//! }
27//! ```
28
29use std::path::PathBuf;
30use thiserror::Error;
31
32// ============================================================================
33// Error Types
34// ============================================================================
35
36/// Error type for Skills integration operations
37///
38/// This enum represents all possible errors that can occur during Skills
39/// integration, including file I/O errors, YAML parsing errors, validation
40/// failures, and security violations.
41#[derive(Debug, Error)]
42pub enum SkillsError {
43    /// I/O error occurred during file operations
44    ///
45    /// This variant wraps standard I/O errors from file reading, writing,
46    /// or directory operations.
47    ///
48    /// # Example
49    ///
50    /// ```
51    /// # use composio_sdk::skills_integration::SkillsError;
52    /// # use std::io;
53    /// let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
54    /// let skills_error: SkillsError = io_error.into();
55    /// ```
56    #[error("I/O error: {0}")]
57    IoError(#[from] std::io::Error),
58
59    /// YAML parsing error occurred
60    ///
61    /// This variant is used when frontmatter YAML parsing fails due to
62    /// invalid syntax or structure.
63    ///
64    /// # Example
65    ///
66    /// ```
67    /// # use composio_sdk::skills_integration::SkillsError;
68    /// let error = SkillsError::YamlError("Invalid YAML syntax".to_string());
69    /// assert!(error.to_string().contains("YAML parsing error"));
70    /// ```
71    #[error("YAML parsing error: {0}")]
72    YamlError(String),
73
74    /// Validation error occurred
75    ///
76    /// This variant is used when Skills directory structure validation fails,
77    /// such as missing required files or invalid frontmatter.
78    ///
79    /// # Example
80    ///
81    /// ```
82    /// # use composio_sdk::skills_integration::SkillsError;
83    /// let error = SkillsError::ValidationError("Missing SKILL.md file".to_string());
84    /// assert!(error.to_string().contains("Validation error"));
85    /// ```
86    #[error("Validation error: {0}")]
87    ValidationError(String),
88
89    /// Path traversal security violation detected
90    ///
91    /// This variant is used when a path attempts to escape the allowed
92    /// directory boundaries, which could be a security risk.
93    ///
94    /// # Example
95    ///
96    /// ```
97    /// # use composio_sdk::skills_integration::SkillsError;
98    /// let error = SkillsError::PathTraversalError(
99    ///     "Path contains '..' components".to_string()
100    /// );
101    /// assert!(error.to_string().contains("Path traversal"));
102    /// ```
103    #[error("Path traversal security violation: {0}")]
104    PathTraversalError(String),
105}
106
107// Implement conversion from serde_yaml::Error to SkillsError
108impl From<serde_yaml::Error> for SkillsError {
109    fn from(err: serde_yaml::Error) -> Self {
110        SkillsError::YamlError(err.to_string())
111    }
112}
113
114// ============================================================================
115// Data Structures
116// ============================================================================
117
118/// Result of a Skills copy operation
119///
120/// Contains statistics about the copy operation including number of files
121/// copied, skipped, and any warnings encountered.
122#[derive(Debug, Clone)]
123pub struct SkillsCopyResult {
124    /// Number of files successfully copied
125    pub files_copied: usize,
126    
127    /// Number of files skipped (e.g., already exist)
128    pub files_skipped: usize,
129    
130    /// List of warning messages encountered during copy
131    pub warnings: Vec<String>,
132    
133    /// Path to the destination directory where files were copied
134    pub destination_path: PathBuf,
135}
136
137/// Result of Skills structure validation
138///
139/// Contains information about the validity of the Skills directory structure
140/// and any missing files.
141#[derive(Debug, Clone)]
142pub struct SkillsValidation {
143    /// Whether the Skills directory structure is valid
144    pub is_valid: bool,
145    
146    /// List of required files that are missing
147    pub missing_required: Vec<String>,
148    
149    /// List of optional files that are missing
150    pub missing_optional: Vec<String>,
151    
152    /// Total number of rule files found
153    pub total_rule_files: usize,
154}
155
156/// Index of Skills files organized by tags
157///
158/// Provides fast lookup of Skills files by tag and maintains statistics
159/// about the total number of Skills available.
160#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
161pub struct SkillsIndex {
162    /// Mapping of tags to lists of file paths
163    ///
164    /// Each tag maps to a vector of relative file paths that contain that tag.
165    /// For example: "tool-router" -> ["rules/tr-userid-best-practices.md", ...]
166    pub skills_by_tag: std::collections::HashMap<String, Vec<String>>,
167    
168    /// Total number of Skills files indexed
169    pub total_skills: usize,
170}
171
172/// Metadata for a single Skills file
173///
174/// Contains information extracted from a Skills file's frontmatter and
175/// file system metadata.
176#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
177pub struct SkillFile {
178    /// Relative path to the Skills file from the steering directory
179    ///
180    /// Example: "rules/tr-userid-best-practices.md"
181    pub path: String,
182    
183    /// Title of the Skills file from frontmatter
184    ///
185    /// Falls back to filename if no title is present in frontmatter.
186    pub title: String,
187    
188    /// Tags associated with this Skills file
189    ///
190    /// Tags are used for categorization and discovery. Examples: "tool-router",
191    /// "security", "authentication"
192    pub tags: Vec<String>,
193    
194    /// Whether the file has valid YAML frontmatter
195    pub has_frontmatter: bool,
196}
197
198// ============================================================================
199// Tests
200// ============================================================================
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::io;
206
207    #[test]
208    fn test_io_error_conversion() {
209        let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
210        let skills_error: SkillsError = io_error.into();
211        
212        match skills_error {
213            SkillsError::IoError(_) => (),
214            _ => panic!("Expected IoError variant"),
215        }
216    }
217
218    #[test]
219    fn test_yaml_error_display() {
220        let error = SkillsError::YamlError("Invalid YAML syntax".to_string());
221        let display = format!("{}", error);
222        
223        assert!(display.contains("YAML parsing error"));
224        assert!(display.contains("Invalid YAML syntax"));
225    }
226
227    #[test]
228    fn test_validation_error_display() {
229        let error = SkillsError::ValidationError("Missing SKILL.md".to_string());
230        let display = format!("{}", error);
231        
232        assert!(display.contains("Validation error"));
233        assert!(display.contains("Missing SKILL.md"));
234    }
235
236    #[test]
237    fn test_path_traversal_error_display() {
238        let error = SkillsError::PathTraversalError(
239            "Path contains '..' components".to_string()
240        );
241        let display = format!("{}", error);
242        
243        assert!(display.contains("Path traversal"));
244        assert!(display.contains("'..'"));
245    }
246
247    #[test]
248    fn test_serde_yaml_error_conversion() {
249        // Create an invalid YAML string to trigger a parsing error
250        let invalid_yaml = "invalid: yaml: syntax:";
251        let yaml_error = serde_yaml::from_str::<serde_yaml::Value>(invalid_yaml)
252            .unwrap_err();
253        
254        let skills_error: SkillsError = yaml_error.into();
255        
256        match skills_error {
257            SkillsError::YamlError(_) => (),
258            _ => panic!("Expected YamlError variant"),
259        }
260    }
261
262    #[test]
263    fn test_skills_copy_result_creation() {
264        let result = SkillsCopyResult {
265            files_copied: 31,
266            files_skipped: 0,
267            warnings: vec!["Optional file missing".to_string()],
268            destination_path: PathBuf::from("/workspace/.kiro/steering/composio"),
269        };
270        
271        assert_eq!(result.files_copied, 31);
272        assert_eq!(result.files_skipped, 0);
273        assert_eq!(result.warnings.len(), 1);
274    }
275
276    #[test]
277    fn test_skills_validation_creation() {
278        let validation = SkillsValidation {
279            is_valid: true,
280            missing_required: vec![],
281            missing_optional: vec!["optional.md".to_string()],
282            total_rule_files: 29,
283        };
284        
285        assert!(validation.is_valid);
286        assert_eq!(validation.missing_required.len(), 0);
287        assert_eq!(validation.missing_optional.len(), 1);
288        assert_eq!(validation.total_rule_files, 29);
289    }
290
291    #[test]
292    fn test_error_is_send_and_sync() {
293        fn assert_send_sync<T: Send + Sync>() {}
294        assert_send_sync::<SkillsError>();
295    }
296
297    #[test]
298    fn test_skills_copy_result_is_clone() {
299        let result = SkillsCopyResult {
300            files_copied: 10,
301            files_skipped: 5,
302            warnings: vec![],
303            destination_path: PathBuf::from("/test"),
304        };
305        
306        let cloned = result.clone();
307        assert_eq!(cloned.files_copied, result.files_copied);
308        assert_eq!(cloned.files_skipped, result.files_skipped);
309    }
310
311    #[test]
312    fn test_skills_validation_is_clone() {
313        let validation = SkillsValidation {
314            is_valid: false,
315            missing_required: vec!["SKILL.md".to_string()],
316            missing_optional: vec![],
317            total_rule_files: 0,
318        };
319        
320        let cloned = validation.clone();
321        assert_eq!(cloned.is_valid, validation.is_valid);
322        assert_eq!(cloned.missing_required, validation.missing_required);
323    }
324
325    #[test]
326    fn test_skills_index_creation() {
327        use std::collections::HashMap;
328        
329        let mut skills_by_tag = HashMap::new();
330        skills_by_tag.insert(
331            "tool-router".to_string(),
332            vec!["rules/tr-userid-best-practices.md".to_string()],
333        );
334        skills_by_tag.insert(
335            "security".to_string(),
336            vec![
337                "rules/tr-userid-best-practices.md".to_string(),
338                "rules/tr-auth-auto.md".to_string(),
339            ],
340        );
341        
342        let index = SkillsIndex {
343            skills_by_tag,
344            total_skills: 29,
345        };
346        
347        assert_eq!(index.total_skills, 29);
348        assert_eq!(index.skills_by_tag.len(), 2);
349        assert_eq!(index.skills_by_tag.get("security").unwrap().len(), 2);
350    }
351
352    #[test]
353    fn test_skills_index_serialization() {
354        use std::collections::HashMap;
355        
356        let mut skills_by_tag = HashMap::new();
357        skills_by_tag.insert(
358            "test-tag".to_string(),
359            vec!["test-file.md".to_string()],
360        );
361        
362        let index = SkillsIndex {
363            skills_by_tag,
364            total_skills: 1,
365        };
366        
367        // Test serialization
368        let json = serde_json::to_string(&index).unwrap();
369        assert!(json.contains("test-tag"));
370        assert!(json.contains("test-file.md"));
371        
372        // Test deserialization
373        let deserialized: SkillsIndex = serde_json::from_str(&json).unwrap();
374        assert_eq!(deserialized.total_skills, 1);
375        assert_eq!(deserialized.skills_by_tag.len(), 1);
376    }
377
378    #[test]
379    fn test_skill_file_creation() {
380        let skill_file = SkillFile {
381            path: "rules/tr-userid-best-practices.md".to_string(),
382            title: "Choose User IDs Carefully for Security and Isolation".to_string(),
383            tags: vec![
384                "tool-router".to_string(),
385                "user-id".to_string(),
386                "security".to_string(),
387            ],
388            has_frontmatter: true,
389        };
390        
391        assert_eq!(skill_file.path, "rules/tr-userid-best-practices.md");
392        assert_eq!(skill_file.tags.len(), 3);
393        assert!(skill_file.has_frontmatter);
394    }
395
396    #[test]
397    fn test_skill_file_serialization() {
398        let skill_file = SkillFile {
399            path: "test.md".to_string(),
400            title: "Test Skill".to_string(),
401            tags: vec!["test".to_string()],
402            has_frontmatter: true,
403        };
404        
405        // Test serialization
406        let json = serde_json::to_string(&skill_file).unwrap();
407        assert!(json.contains("test.md"));
408        assert!(json.contains("Test Skill"));
409        
410        // Test deserialization
411        let deserialized: SkillFile = serde_json::from_str(&json).unwrap();
412        assert_eq!(deserialized.path, "test.md");
413        assert_eq!(deserialized.title, "Test Skill");
414        assert_eq!(deserialized.tags.len(), 1);
415    }
416
417    #[test]
418    fn test_skills_index_is_clone() {
419        use std::collections::HashMap;
420        
421        let mut skills_by_tag = HashMap::new();
422        skills_by_tag.insert("tag1".to_string(), vec!["file1.md".to_string()]);
423        
424        let index = SkillsIndex {
425            skills_by_tag,
426            total_skills: 1,
427        };
428        
429        let cloned = index.clone();
430        assert_eq!(cloned.total_skills, index.total_skills);
431        assert_eq!(cloned.skills_by_tag.len(), index.skills_by_tag.len());
432    }
433
434    #[test]
435    fn test_skill_file_is_clone() {
436        let skill_file = SkillFile {
437            path: "test.md".to_string(),
438            title: "Test".to_string(),
439            tags: vec!["tag1".to_string()],
440            has_frontmatter: false,
441        };
442        
443        let cloned = skill_file.clone();
444        assert_eq!(cloned.path, skill_file.path);
445        assert_eq!(cloned.title, skill_file.title);
446        assert_eq!(cloned.has_frontmatter, skill_file.has_frontmatter);
447    }
448}
449
450// ============================================================================
451// Skills Structure Validation
452// ============================================================================
453
454/// Validates that the source Skills directory has the expected structure
455///
456/// This function checks for the presence of required files and directories
457/// in the bundled Composio Skills directory. It verifies:
458///
459/// - The skills/ directory exists
460/// - SKILL.md file exists (required)
461/// - AGENTS.md file exists (required)
462/// - rules/ directory exists (required)
463/// - At least one rule file exists in rules/ directory
464///
465/// # Arguments
466///
467/// * `skills_dir` - Path to the bundled skills directory (e.g., "composio-sdk/skills")
468///
469/// # Returns
470///
471/// * `Ok(SkillsValidation)` - Validation result with details about missing files
472/// * `Err(SkillsError)` - If the skills directory doesn't exist or cannot be accessed
473///
474/// # Example
475///
476/// ```no_run
477/// use composio_sdk::skills_integration::validate_skills_structure;
478/// use std::path::Path;
479///
480/// #[tokio::main]
481/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
482///     let skills_path = Path::new("composio-sdk/skills");
483///     let validation = validate_skills_structure(skills_path).await?;
484///     
485///     if validation.is_valid {
486///         println!("Skills structure is valid!");
487///         println!("Found {} rule files", validation.total_rule_files);
488///     } else {
489///         println!("Validation failed:");
490///         for missing in &validation.missing_required {
491///             println!("  - Missing required file: {}", missing);
492///         }
493///     }
494///     
495///     Ok(())
496/// }
497/// ```
498///
499/// # Errors
500///
501/// Returns `SkillsError::IoError` if:
502/// - The vendor directory doesn't exist
503/// - Directory permissions prevent reading
504/// - File system errors occur during validation
505pub async fn validate_skills_structure(
506    vendor_dir: &std::path::Path,
507) -> Result<SkillsValidation, SkillsError> {
508    use tokio::fs;
509    
510    // Check if source directory exists
511    if !vendor_dir.exists() {
512        return Ok(SkillsValidation {
513            is_valid: false,
514            missing_required: vec![
515                format!("Source directory not found: {}", vendor_dir.display())
516            ],
517            missing_optional: vec![],
518            total_rule_files: 0,
519        });
520    }
521    
522    // Check if it's actually a directory
523    let metadata = fs::metadata(vendor_dir).await?;
524    if !metadata.is_dir() {
525        return Ok(SkillsValidation {
526            is_valid: false,
527            missing_required: vec![
528                format!("Path is not a directory: {}", vendor_dir.display())
529            ],
530            missing_optional: vec![],
531            total_rule_files: 0,
532        });
533    }
534    
535    let mut missing_required = Vec::new();
536    let mut missing_optional = Vec::new();
537    
538    // Verify required files: SKILL.md
539    let skill_md = vendor_dir.join("SKILL.md");
540    if !skill_md.exists() {
541        missing_required.push("SKILL.md".to_string());
542    }
543    
544    // Verify required files: AGENTS.md
545    let agents_md = vendor_dir.join("AGENTS.md");
546    if !agents_md.exists() {
547        missing_required.push("AGENTS.md".to_string());
548    }
549    
550    // Verify required directory: rules/
551    let rules_dir = vendor_dir.join("rules");
552    let mut total_rule_files = 0;
553    
554    if !rules_dir.exists() {
555        missing_required.push("rules/ directory".to_string());
556    } else {
557        // Check if it's a directory
558        let rules_metadata = fs::metadata(&rules_dir).await?;
559        if !rules_metadata.is_dir() {
560            missing_required.push("rules/ is not a directory".to_string());
561        } else {
562            // Count rule files (*.md files in rules/ directory)
563            let mut entries = fs::read_dir(&rules_dir).await?;
564            
565            while let Some(entry) = entries.next_entry().await? {
566                let path = entry.path();
567                
568                // Check if it's a markdown file
569                if path.is_file() {
570                    if let Some(extension) = path.extension() {
571                        if extension == "md" {
572                            total_rule_files += 1;
573                        }
574                    }
575                }
576            }
577            
578            // Warn if no rule files found
579            if total_rule_files == 0 {
580                missing_optional.push(
581                    "No markdown files found in rules/ directory".to_string()
582                );
583            }
584        }
585    }
586    
587    // Validation is successful if no required files are missing
588    let is_valid = missing_required.is_empty();
589    
590    Ok(SkillsValidation {
591        is_valid,
592        missing_required,
593        missing_optional,
594        total_rule_files,
595    })
596}
597
598// ============================================================================
599// Validation Tests
600// ============================================================================
601
602#[cfg(test)]
603mod validation_tests {
604    use super::*;
605    use std::path::PathBuf;
606    use tokio::fs;
607    
608    /// Helper function to create a temporary test directory
609    async fn create_test_dir() -> Result<tempfile::TempDir, Box<dyn std::error::Error>> {
610        let temp_dir = tempfile::tempdir()?;
611        Ok(temp_dir)
612    }
613    
614    /// Helper function to create a test Skills directory structure
615    async fn create_test_skills_structure(
616        base_dir: &std::path::Path,
617        include_skill_md: bool,
618        include_agents_md: bool,
619        include_rules_dir: bool,
620        num_rule_files: usize,
621    ) -> Result<(), Box<dyn std::error::Error>> {
622        // Create SKILL.md if requested
623        if include_skill_md {
624            let skill_path = base_dir.join("SKILL.md");
625            fs::write(&skill_path, "# Composio Skills\n\nTest content").await?;
626        }
627        
628        // Create AGENTS.md if requested
629        if include_agents_md {
630            let agents_path = base_dir.join("AGENTS.md");
631            fs::write(&agents_path, "# Agents\n\nTest content").await?;
632        }
633        
634        // Create rules/ directory if requested
635        if include_rules_dir {
636            let rules_dir = base_dir.join("rules");
637            fs::create_dir(&rules_dir).await?;
638            
639            // Create rule files
640            for i in 0..num_rule_files {
641                let rule_path = rules_dir.join(format!("rule-{}.md", i));
642                fs::write(&rule_path, format!("# Rule {}\n\nTest content", i)).await?;
643            }
644        }
645        
646        Ok(())
647    }
648    
649    #[tokio::test]
650    async fn test_validate_skills_structure_with_valid_directory() {
651        let temp_dir = create_test_dir().await.unwrap();
652        let vendor_path = temp_dir.path();
653        
654        // Create valid structure
655        create_test_skills_structure(vendor_path, true, true, true, 5)
656            .await
657            .unwrap();
658        
659        let validation = validate_skills_structure(vendor_path).await.unwrap();
660        
661        assert!(validation.is_valid);
662        assert_eq!(validation.missing_required.len(), 0);
663        assert_eq!(validation.total_rule_files, 5);
664    }
665    
666    #[tokio::test]
667    async fn test_validate_skills_structure_missing_skill_md() {
668        let temp_dir = create_test_dir().await.unwrap();
669        let vendor_path = temp_dir.path();
670        
671        // Create structure without SKILL.md
672        create_test_skills_structure(vendor_path, false, true, true, 3)
673            .await
674            .unwrap();
675        
676        let validation = validate_skills_structure(vendor_path).await.unwrap();
677        
678        assert!(!validation.is_valid);
679        assert!(validation.missing_required.contains(&"SKILL.md".to_string()));
680        assert_eq!(validation.total_rule_files, 3);
681    }
682    
683    #[tokio::test]
684    async fn test_validate_skills_structure_missing_agents_md() {
685        let temp_dir = create_test_dir().await.unwrap();
686        let vendor_path = temp_dir.path();
687        
688        // Create structure without AGENTS.md
689        create_test_skills_structure(vendor_path, true, false, true, 3)
690            .await
691            .unwrap();
692        
693        let validation = validate_skills_structure(vendor_path).await.unwrap();
694        
695        assert!(!validation.is_valid);
696        assert!(validation.missing_required.contains(&"AGENTS.md".to_string()));
697    }
698    
699    #[tokio::test]
700    async fn test_validate_skills_structure_missing_rules_directory() {
701        let temp_dir = create_test_dir().await.unwrap();
702        let vendor_path = temp_dir.path();
703        
704        // Create structure without rules/ directory
705        create_test_skills_structure(vendor_path, true, true, false, 0)
706            .await
707            .unwrap();
708        
709        let validation = validate_skills_structure(vendor_path).await.unwrap();
710        
711        assert!(!validation.is_valid);
712        assert!(validation
713            .missing_required
714            .iter()
715            .any(|s| s.contains("rules/")));
716        assert_eq!(validation.total_rule_files, 0);
717    }
718    
719    #[tokio::test]
720    async fn test_validate_skills_structure_empty_rules_directory() {
721        let temp_dir = create_test_dir().await.unwrap();
722        let vendor_path = temp_dir.path();
723        
724        // Create structure with empty rules/ directory
725        create_test_skills_structure(vendor_path, true, true, true, 0)
726            .await
727            .unwrap();
728        
729        let validation = validate_skills_structure(vendor_path).await.unwrap();
730        
731        // Should be valid but with a warning
732        assert!(validation.is_valid);
733        assert!(validation
734            .missing_optional
735            .iter()
736            .any(|s| s.contains("No markdown files")));
737        assert_eq!(validation.total_rule_files, 0);
738    }
739    
740    #[tokio::test]
741    async fn test_validate_skills_structure_nonexistent_directory() {
742        let nonexistent_path = PathBuf::from("/nonexistent/path/to/skills");
743        
744        let validation = validate_skills_structure(&nonexistent_path)
745            .await
746            .unwrap();
747        
748        assert!(!validation.is_valid);
749        assert!(validation
750            .missing_required
751            .iter()
752            .any(|s| s.contains("Source directory not found")));
753    }
754    
755    #[tokio::test]
756    async fn test_validate_skills_structure_path_is_file() {
757        let temp_dir = create_test_dir().await.unwrap();
758        let file_path = temp_dir.path().join("not-a-directory.txt");
759        
760        // Create a file instead of a directory
761        fs::write(&file_path, "test content").await.unwrap();
762        
763        let validation = validate_skills_structure(&file_path).await.unwrap();
764        
765        assert!(!validation.is_valid);
766        assert!(validation
767            .missing_required
768            .iter()
769            .any(|s| s.contains("not a directory")));
770    }
771    
772    #[tokio::test]
773    async fn test_validate_skills_structure_multiple_missing_files() {
774        let temp_dir = create_test_dir().await.unwrap();
775        let vendor_path = temp_dir.path();
776        
777        // Create structure with multiple missing files
778        create_test_skills_structure(vendor_path, false, false, false, 0)
779            .await
780            .unwrap();
781        
782        let validation = validate_skills_structure(vendor_path).await.unwrap();
783        
784        assert!(!validation.is_valid);
785        assert!(validation.missing_required.len() >= 3);
786        assert!(validation.missing_required.contains(&"SKILL.md".to_string()));
787        assert!(validation.missing_required.contains(&"AGENTS.md".to_string()));
788        assert!(validation
789            .missing_required
790            .iter()
791            .any(|s| s.contains("rules/")));
792    }
793    
794    #[tokio::test]
795    async fn test_validate_skills_structure_with_many_rule_files() {
796        let temp_dir = create_test_dir().await.unwrap();
797        let vendor_path = temp_dir.path();
798        
799        // Create structure with many rule files (like the real Composio Skills)
800        create_test_skills_structure(vendor_path, true, true, true, 29)
801            .await
802            .unwrap();
803        
804        let validation = validate_skills_structure(vendor_path).await.unwrap();
805        
806        assert!(validation.is_valid);
807        assert_eq!(validation.missing_required.len(), 0);
808        assert_eq!(validation.total_rule_files, 29);
809    }
810    
811    #[tokio::test]
812    async fn test_validate_skills_structure_rules_is_file_not_directory() {
813        let temp_dir = create_test_dir().await.unwrap();
814        let vendor_path = temp_dir.path();
815        
816        // Create SKILL.md and AGENTS.md
817        create_test_skills_structure(vendor_path, true, true, false, 0)
818            .await
819            .unwrap();
820        
821        // Create rules as a file instead of a directory
822        let rules_path = vendor_path.join("rules");
823        fs::write(&rules_path, "not a directory").await.unwrap();
824        
825        let validation = validate_skills_structure(vendor_path).await.unwrap();
826        
827        assert!(!validation.is_valid);
828        assert!(validation
829            .missing_required
830            .iter()
831            .any(|s| s.contains("rules/") && s.contains("not a directory")));
832    }
833}
834
835// ============================================================================
836// Frontmatter Management
837// ============================================================================
838
839/// Adds or updates the `inclusion: auto` frontmatter field in a markdown file
840///
841/// This function handles three scenarios:
842/// 1. File has existing frontmatter: Adds `inclusion: auto` field while preserving other fields
843/// 2. File has no frontmatter: Creates minimal frontmatter with `inclusion: auto`
844/// 3. File has malformed YAML: Returns error with details
845///
846/// The function preserves all existing frontmatter fields and only adds or updates
847/// the `inclusion` field. This ensures that metadata like title, tags, impact, and
848/// description are maintained.
849///
850/// # Arguments
851///
852/// * `content` - The original markdown file content
853///
854/// # Returns
855///
856/// * `Ok(String)` - Modified content with frontmatter containing `inclusion: auto`
857/// * `Err(SkillsError)` - If YAML parsing fails or frontmatter is malformed
858///
859/// # Example
860///
861/// ```
862/// use composio_sdk::skills_integration::add_auto_inclusion_frontmatter;
863///
864/// // File without frontmatter
865/// let content = "# My Skill\n\nThis is a skill file.";
866/// let result = add_auto_inclusion_frontmatter(content).unwrap();
867/// assert!(result.contains("inclusion: auto"));
868///
869/// // File with existing frontmatter
870/// let content = "---\ntitle: My Skill\ntags:\n  - test\n---\n\n# Content";
871/// let result = add_auto_inclusion_frontmatter(content).unwrap();
872/// assert!(result.contains("inclusion: auto"));
873/// assert!(result.contains("title: My Skill"));
874/// ```
875///
876/// # Errors
877///
878/// Returns `SkillsError::YamlError` if:
879/// - Frontmatter YAML syntax is invalid
880/// - Frontmatter structure cannot be parsed
881/// - YAML serialization fails
882pub fn add_auto_inclusion_frontmatter(content: &str) -> Result<String, SkillsError> {
883    use serde_yaml::Value;
884
885    // Check if content starts with frontmatter delimiter
886    if content.starts_with("---") {
887        // Find the end of frontmatter (second "---")
888        let content_after_first_delimiter = &content[3..];
889
890        if let Some(end_pos) = content_after_first_delimiter.find("\n---") {
891            // Extract frontmatter YAML (between the two "---")
892            let frontmatter_yaml = &content_after_first_delimiter[..end_pos];
893
894            // Extract body (after the second "---")
895            let body_start = 3 + end_pos + 4; // 3 for first "---", end_pos, 4 for "\n---"
896            let body = if body_start < content.len() {
897                &content[body_start..]
898            } else {
899                ""
900            };
901
902            // Parse existing frontmatter
903            let mut frontmatter: serde_yaml::Mapping = serde_yaml::from_str(frontmatter_yaml)
904                .map_err(|e| SkillsError::YamlError(format!("Failed to parse frontmatter: {}", e)))?;
905
906            // Add or update the inclusion field
907            frontmatter.insert(
908                Value::String("inclusion".to_string()),
909                Value::String("auto".to_string()),
910            );
911
912            // Serialize back to YAML
913            let new_yaml = serde_yaml::to_string(&frontmatter)
914                .map_err(|e| SkillsError::YamlError(format!("Failed to serialize frontmatter: {}", e)))?;
915
916            // Reconstruct the file with updated frontmatter
917            Ok(format!("---\n{}---{}", new_yaml, body))
918        } else {
919            // Malformed frontmatter: starts with "---" but no closing "---"
920            Err(SkillsError::YamlError(
921                "Malformed frontmatter: missing closing '---' delimiter".to_string()
922            ))
923        }
924    } else {
925        // No frontmatter exists, create minimal one
926        let frontmatter = "---\ninclusion: auto\n---\n\n";
927        Ok(format!("{}{}", frontmatter, content))
928    }
929}
930
931// ============================================================================
932// Frontmatter Management Tests
933// ============================================================================
934
935#[cfg(test)]
936mod frontmatter_tests {
937    use super::*;
938
939    #[test]
940    fn test_add_frontmatter_to_file_without_frontmatter() {
941        let content = "# My Skill\n\nThis is a skill file.";
942        let result = add_auto_inclusion_frontmatter(content).unwrap();
943
944        assert!(result.starts_with("---\n"));
945        assert!(result.contains("inclusion: auto"));
946        assert!(result.contains("# My Skill"));
947        assert!(result.contains("This is a skill file."));
948    }
949
950    #[test]
951    fn test_add_frontmatter_to_empty_file() {
952        let content = "";
953        let result = add_auto_inclusion_frontmatter(content).unwrap();
954
955        assert!(result.starts_with("---\n"));
956        assert!(result.contains("inclusion: auto"));
957        assert!(result.ends_with("---\n\n"));
958    }
959
960    #[test]
961    fn test_preserve_existing_frontmatter_fields() {
962        let content = r#"---
963title: My Skill
964impact: HIGH
965description: A test skill
966tags:
967  - test
968  - example
969---
970
971# Content here"#;
972
973        let result = add_auto_inclusion_frontmatter(content).unwrap();
974
975        assert!(result.contains("inclusion: auto"));
976        assert!(result.contains("title: My Skill"));
977        assert!(result.contains("impact: HIGH"));
978        assert!(result.contains("description: A test skill"));
979        assert!(result.contains("tags:"));
980        assert!(result.contains("- test"));
981        assert!(result.contains("- example"));
982        assert!(result.contains("# Content here"));
983    }
984
985    #[test]
986    fn test_update_existing_inclusion_field() {
987        let content = r#"---
988title: My Skill
989inclusion: manual
990---
991
992# Content"#;
993
994        let result = add_auto_inclusion_frontmatter(content).unwrap();
995
996        assert!(result.contains("inclusion: auto"));
997        assert!(!result.contains("inclusion: manual"));
998        assert!(result.contains("title: My Skill"));
999    }
1000
1001    #[test]
1002    fn test_handle_frontmatter_with_complex_yaml() {
1003        let content = r#"---
1004title: Complex Skill
1005nested:
1006  field1: value1
1007  field2: value2
1008list:
1009  - item1
1010  - item2
1011  - item3
1012---
1013
1014# Content"#;
1015
1016        let result = add_auto_inclusion_frontmatter(content).unwrap();
1017
1018        assert!(result.contains("inclusion: auto"));
1019        assert!(result.contains("title: Complex Skill"));
1020        assert!(result.contains("nested:"));
1021        assert!(result.contains("field1: value1"));
1022        assert!(result.contains("list:"));
1023        assert!(result.contains("- item1"));
1024    }
1025
1026    #[test]
1027    fn test_malformed_frontmatter_missing_closing_delimiter() {
1028        let content = r#"---
1029title: My Skill
1030tags:
1031  - test
1032
1033# Content without closing ---"#;
1034
1035        let result = add_auto_inclusion_frontmatter(content);
1036
1037        assert!(result.is_err());
1038        match result {
1039            Err(SkillsError::YamlError(msg)) => {
1040                assert!(msg.contains("missing closing"));
1041            }
1042            _ => panic!("Expected YamlError"),
1043        }
1044    }
1045
1046    #[test]
1047    fn test_malformed_yaml_syntax() {
1048        let content = r#"---
1049title: My Skill
1050invalid: yaml: syntax:
1051---
1052
1053# Content"#;
1054
1055        let result = add_auto_inclusion_frontmatter(content);
1056
1057        assert!(result.is_err());
1058        match result {
1059            Err(SkillsError::YamlError(_)) => (),
1060            _ => panic!("Expected YamlError"),
1061        }
1062    }
1063
1064    #[test]
1065    fn test_frontmatter_with_special_characters() {
1066        let content = r#"---
1067title: "Skill with: special characters"
1068description: "Contains \"quotes\" and 'apostrophes'"
1069---
1070
1071# Content"#;
1072
1073        let result = add_auto_inclusion_frontmatter(content).unwrap();
1074
1075        assert!(result.contains("inclusion: auto"));
1076        assert!(result.contains("title:"));
1077        assert!(result.contains("description:"));
1078    }
1079
1080    #[test]
1081    fn test_frontmatter_with_multiline_strings() {
1082        let content = r#"---
1083title: My Skill
1084description: |
1085  This is a multiline
1086  description that spans
1087  multiple lines
1088---
1089
1090# Content"#;
1091
1092        let result = add_auto_inclusion_frontmatter(content).unwrap();
1093
1094        assert!(result.contains("inclusion: auto"));
1095        assert!(result.contains("title: My Skill"));
1096        assert!(result.contains("description:"));
1097    }
1098
1099    #[test]
1100    fn test_preserve_body_formatting() {
1101        let content = r#"---
1102title: My Skill
1103---
1104
1105# Heading 1
1106
1107Some paragraph with **bold** and *italic*.
1108
1109## Heading 2
1110
1111- List item 1
1112- List item 2
1113
1114```rust
1115fn example() {
1116    println!("code block");
1117}
1118```"#;
1119
1120        let result = add_auto_inclusion_frontmatter(content).unwrap();
1121
1122        assert!(result.contains("inclusion: auto"));
1123        assert!(result.contains("# Heading 1"));
1124        assert!(result.contains("**bold**"));
1125        assert!(result.contains("*italic*"));
1126        assert!(result.contains("## Heading 2"));
1127        assert!(result.contains("- List item 1"));
1128        assert!(result.contains("```rust"));
1129        assert!(result.contains("fn example()"));
1130    }
1131
1132    #[test]
1133    fn test_empty_frontmatter() {
1134        let content = r#"---
1135---
1136
1137# Content"#;
1138
1139        let result = add_auto_inclusion_frontmatter(content).unwrap();
1140
1141        assert!(result.contains("inclusion: auto"));
1142        assert!(result.contains("# Content"));
1143    }
1144
1145    #[test]
1146    fn test_frontmatter_with_only_inclusion() {
1147        let content = r#"---
1148inclusion: manual
1149---
1150
1151# Content"#;
1152
1153        let result = add_auto_inclusion_frontmatter(content).unwrap();
1154
1155        assert!(result.contains("inclusion: auto"));
1156        assert!(!result.contains("inclusion: manual"));
1157    }
1158
1159    #[test]
1160    fn test_frontmatter_with_numeric_values() {
1161        let content = r#"---
1162title: My Skill
1163priority: 1
1164version: 2.5
1165enabled: true
1166---
1167
1168# Content"#;
1169
1170        let result = add_auto_inclusion_frontmatter(content).unwrap();
1171
1172        assert!(result.contains("inclusion: auto"));
1173        assert!(result.contains("title: My Skill"));
1174        assert!(result.contains("priority:"));
1175        assert!(result.contains("version:"));
1176        assert!(result.contains("enabled:"));
1177    }
1178
1179    #[test]
1180    fn test_frontmatter_with_null_values() {
1181        let content = r#"---
1182title: My Skill
1183optional_field: null
1184---
1185
1186# Content"#;
1187
1188        let result = add_auto_inclusion_frontmatter(content).unwrap();
1189
1190        assert!(result.contains("inclusion: auto"));
1191        assert!(result.contains("title: My Skill"));
1192    }
1193
1194    #[test]
1195    fn test_content_starting_with_dashes_but_not_frontmatter() {
1196        // Content that starts with "---" but has no closing delimiter
1197        // This is treated as malformed frontmatter (safer behavior)
1198        let content = "--- This is not frontmatter\n\nJust regular content.";
1199        let result = add_auto_inclusion_frontmatter(content);
1200
1201        // Should return error for malformed frontmatter
1202        assert!(result.is_err());
1203        match result {
1204            Err(SkillsError::YamlError(msg)) => {
1205                assert!(msg.contains("missing closing"));
1206            }
1207            _ => panic!("Expected YamlError"),
1208        }
1209    }
1210
1211    #[test]
1212    fn test_frontmatter_preserves_field_order() {
1213        let content = r#"---
1214title: My Skill
1215impact: HIGH
1216description: Test
1217tags:
1218  - tag1
1219---
1220
1221# Content"#;
1222
1223        let result = add_auto_inclusion_frontmatter(content).unwrap();
1224
1225        // Verify all fields are present (order may vary in YAML)
1226        assert!(result.contains("inclusion: auto"));
1227        assert!(result.contains("title:"));
1228        assert!(result.contains("impact:"));
1229        assert!(result.contains("description:"));
1230        assert!(result.contains("tags:"));
1231    }
1232}
1233
1234