Skip to main content

fastskill_core/core/
change_detection.rs

1//! Change detection for skills using git and file hashing
2
3use crate::core::build_cache::BuildCache;
4use crate::core::service::ServiceError;
5use std::collections::HashSet;
6use std::path::Path;
7use tracing::{debug, info};
8
9/// Detect changed skills using git diff
10pub fn detect_changed_skills_git(
11    base_ref: &str,
12    head_ref: &str,
13    skills_dir: &Path,
14) -> Result<Vec<String>, ServiceError> {
15    info!(
16        "Detecting changed skills using git diff: {}..{}",
17        base_ref, head_ref
18    );
19
20    use std::process::Command;
21
22    // Use git diff to get changed files
23    let output = Command::new("git")
24        .args(["diff", "--name-only", base_ref, head_ref])
25        .output()
26        .map_err(|e| ServiceError::Custom(format!("Failed to execute git diff: {}", e)))?;
27
28    if !output.status.success() {
29        return Err(ServiceError::Custom(format!(
30            "Git diff failed: {}",
31            String::from_utf8_lossy(&output.stderr)
32        )));
33    }
34
35    // Parse output and collect changed skill directories
36    let mut changed_skills = HashSet::new();
37    let skills_dir_str = skills_dir.to_string_lossy().to_string();
38    let output_str = String::from_utf8_lossy(&output.stdout);
39
40    for line in output_str.lines() {
41        let path_str = line.trim();
42        if path_str.starts_with(&skills_dir_str) {
43            // Extract skill ID from path (e.g., "skills/web-scraper/file.md" -> "web-scraper")
44            let relative_path = path_str
45                .strip_prefix(&skills_dir_str)
46                .and_then(|p| p.strip_prefix('/'))
47                .or_else(|| path_str.strip_prefix(&skills_dir_str))
48                .unwrap_or(path_str);
49
50            if let Some(skill_id) = relative_path.split('/').next() {
51                if !skill_id.is_empty() {
52                    changed_skills.insert(skill_id.to_string());
53                }
54            }
55        }
56    }
57
58    let skills: Vec<String> = changed_skills.into_iter().collect();
59    info!("Found {} changed skills via git diff", skills.len());
60    Ok(skills)
61}
62
63/// Detect changed skills using file hash comparison
64pub fn detect_changed_skills_hash(
65    skills_dir: &Path,
66    cache: &BuildCache,
67) -> Result<Vec<String>, ServiceError> {
68    info!("Detecting changed skills using file hash comparison");
69
70    if !skills_dir.exists() {
71        return Ok(Vec::new());
72    }
73
74    let mut changed_skills = Vec::new();
75
76    // Scan for skill directories
77    let entries = std::fs::read_dir(skills_dir).map_err(ServiceError::Io)?;
78
79    for entry in entries {
80        let entry = entry.map_err(ServiceError::Io)?;
81        let path = entry.path();
82
83        if path.is_dir() {
84            if let Some(skill_id) = path.file_name().and_then(|n| n.to_str()) {
85                // Check if SKILL.md exists (indicates this is a skill directory)
86                if path.join("SKILL.md").exists() {
87                    let current_hash = calculate_skill_hash(&path)?;
88
89                    // Check if hash changed
90                    let cached_hash = cache.get_cached_hash(skill_id);
91
92                    if cached_hash
93                        .as_ref()
94                        .map(|h| h != &current_hash)
95                        .unwrap_or(true)
96                    {
97                        debug!(
98                            "Skill '{}' changed (hash: {} -> {})",
99                            skill_id,
100                            cached_hash.as_deref().unwrap_or("none"),
101                            &current_hash
102                        );
103                        changed_skills.push(skill_id.to_string());
104                    }
105                }
106            }
107        }
108    }
109
110    info!(
111        "Found {} changed skills via hash comparison",
112        changed_skills.len()
113    );
114    Ok(changed_skills)
115}
116
117/// Calculate SHA256 hash of all files in a skill directory
118pub fn calculate_skill_hash(skill_path: &Path) -> Result<String, ServiceError> {
119    use sha2::{Digest, Sha256};
120    use std::fs;
121    use std::path::PathBuf;
122
123    let mut hasher = Sha256::new();
124
125    // Walk through all files in the skill directory
126    let entries = walkdir::WalkDir::new(skill_path)
127        .into_iter()
128        .filter_map(|e| e.ok())
129        .filter(|e| e.file_type().is_file());
130
131    let mut file_paths: Vec<PathBuf> = entries.map(|e| e.path().to_path_buf()).collect();
132
133    // Sort to ensure consistent hashing
134    file_paths.sort();
135
136    for file_path in file_paths {
137        // Read file content
138        let content = fs::read(&file_path).map_err(ServiceError::Io)?;
139
140        // Include relative path in hash
141        let relative_path = file_path
142            .strip_prefix(skill_path)
143            .unwrap_or(&file_path)
144            .to_string_lossy();
145
146        hasher.update(relative_path.as_bytes());
147        hasher.update(&content);
148    }
149
150    let hash = format!("sha256:{:x}", hasher.finalize());
151    Ok(hash)
152}