fastskill_core/core/
change_detection.rs1use crate::core::build_cache::BuildCache;
4use crate::core::service::ServiceError;
5use std::collections::HashSet;
6use std::path::Path;
7use tracing::{debug, info};
8
9pub 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 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 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 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
63pub 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 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 if path.join("SKILL.md").exists() {
87 let current_hash = calculate_skill_hash(&path)?;
88
89 let cached_hash = cache.get_cached_hash(skill_id);
91
92 if cached_hash
93 .as_ref()
94 .map(|h| h != ¤t_hash)
95 .unwrap_or(true)
96 {
97 debug!(
98 "Skill '{}' changed (hash: {} -> {})",
99 skill_id,
100 cached_hash.as_deref().unwrap_or("none"),
101 ¤t_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
117pub 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 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 file_paths.sort();
135
136 for file_path in file_paths {
137 let content = fs::read(&file_path).map_err(ServiceError::Io)?;
139
140 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}