1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use sha2::{Digest, Sha256};
11use thiserror::Error;
12use tracing::debug;
13
14#[derive(Debug, Error)]
16pub enum SkillError {
17 #[error("invalid URL: {0}")]
18 InvalidUrl(String),
19 #[error("network error: {0}")]
20 NetworkError(String),
21 #[error("I/O error: {0}")]
22 IoError(#[from] std::io::Error),
23 #[error("skill parse error: {0}")]
24 ParseError(String),
25 #[error("URL scheme must be https: {0}")]
26 InsecureUrl(String),
27}
28
29#[derive(Debug, Clone)]
31pub struct Skill {
32 pub name: String,
34 pub content: String,
36 pub source_url: Option<String>,
38 pub cache_path: Option<PathBuf>,
40 pub sections: HashMap<String, String>,
42}
43
44fn default_skills_dir() -> PathBuf {
46 dirs::home_dir()
47 .unwrap_or_else(|| PathBuf::from("/tmp"))
48 .join(".opendev")
49 .join("skills")
50}
51
52fn url_to_cache_filename(url: &str) -> String {
54 let mut hasher = Sha256::new();
55 hasher.update(url.as_bytes());
56 let hash = hasher.finalize();
57 let hex: String = hash.iter().take(8).map(|b| format!("{b:02x}")).collect();
58 format!("{hex}.md")
59}
60
61pub fn parse_skill(content: &str, fallback_name: &str) -> Result<Skill, SkillError> {
66 if content.trim().is_empty() {
67 return Err(SkillError::ParseError("skill content is empty".to_string()));
68 }
69
70 let mut name = fallback_name.to_string();
71 let mut sections: HashMap<String, String> = HashMap::new();
72 let mut current_section = String::new();
73 let mut current_body = String::new();
74
75 for line in content.lines() {
76 if let Some(heading) = line.strip_prefix("# ") {
77 if name == fallback_name && !heading.trim().is_empty() {
79 name = heading.trim().to_string();
80 }
81 if !current_section.is_empty() {
83 sections.insert(current_section.clone(), current_body.trim().to_string());
84 }
85 current_section = heading.trim().to_string();
86 current_body.clear();
87 } else if let Some(heading) = line.strip_prefix("## ") {
88 if !current_section.is_empty() {
89 sections.insert(current_section.clone(), current_body.trim().to_string());
90 }
91 current_section = heading.trim().to_string();
92 current_body.clear();
93 } else {
94 current_body.push_str(line);
95 current_body.push('\n');
96 }
97 }
98
99 if !current_section.is_empty() {
101 sections.insert(current_section, current_body.trim().to_string());
102 }
103
104 Ok(Skill {
105 name,
106 content: content.to_string(),
107 source_url: None,
108 cache_path: None,
109 sections,
110 })
111}
112
113pub fn load_skill_from_url(url: &str) -> Result<Skill, SkillError> {
119 load_skill_from_url_with_options(url, None, false)
120}
121
122pub fn load_skill_from_url_with_options(
124 url: &str,
125 cache_dir: Option<&Path>,
126 force_refresh: bool,
127) -> Result<Skill, SkillError> {
128 if !url.starts_with("https://") {
130 return Err(SkillError::InsecureUrl(url.to_string()));
131 }
132
133 if !url.contains('.') || url.len() < 12 {
134 return Err(SkillError::InvalidUrl(url.to_string()));
135 }
136
137 let skills_dir = cache_dir
138 .map(PathBuf::from)
139 .unwrap_or_else(default_skills_dir);
140 let cache_filename = url_to_cache_filename(url);
141 let cache_path = skills_dir.join(&cache_filename);
142
143 if !force_refresh && cache_path.exists() {
145 debug!("Loading cached skill from {:?}", cache_path);
146 let content = std::fs::read_to_string(&cache_path)?;
147 let fallback_name = extract_name_from_url(url);
148 let mut skill = parse_skill(&content, &fallback_name)?;
149 skill.source_url = Some(url.to_string());
150 skill.cache_path = Some(cache_path);
151 return Ok(skill);
152 }
153
154 debug!("Fetching skill from {}", url);
156 let content = fetch_url_content(url)?;
157
158 std::fs::create_dir_all(&skills_dir)?;
160 std::fs::write(&cache_path, &content)?;
161 debug!("Cached skill to {:?}", cache_path);
162
163 let fallback_name = extract_name_from_url(url);
164 let mut skill = parse_skill(&content, &fallback_name)?;
165 skill.source_url = Some(url.to_string());
166 skill.cache_path = Some(cache_path);
167
168 Ok(skill)
169}
170
171fn extract_name_from_url(url: &str) -> String {
173 url.rsplit('/')
174 .next()
175 .unwrap_or("remote-skill")
176 .trim_end_matches(".md")
177 .replace(['-', '_'], " ")
178}
179
180fn fetch_url_content(url: &str) -> Result<String, SkillError> {
182 let url_owned = url.to_string();
183
184 let fetch = async move {
185 let client = reqwest::Client::builder()
186 .timeout(std::time::Duration::from_secs(15))
187 .user_agent("opendev-rust/0.1.0")
188 .build()
189 .map_err(|e| SkillError::NetworkError(e.to_string()))?;
190
191 let resp = client
192 .get(&url_owned)
193 .send()
194 .await
195 .map_err(|e| SkillError::NetworkError(e.to_string()))?;
196
197 if !resp.status().is_success() {
198 return Err(SkillError::NetworkError(format!(
199 "HTTP {} for {}",
200 resp.status(),
201 url_owned
202 )));
203 }
204
205 resp.text()
206 .await
207 .map_err(|e| SkillError::NetworkError(e.to_string()))
208 };
209
210 match tokio::runtime::Handle::try_current() {
212 Ok(_handle) => std::thread::scope(|s| {
213 s.spawn(|| {
214 tokio::runtime::Builder::new_current_thread()
215 .enable_all()
216 .build()
217 .map_err(|e| SkillError::NetworkError(e.to_string()))
218 .and_then(|rt| rt.block_on(fetch))
219 })
220 .join()
221 .unwrap_or_else(|_| Err(SkillError::NetworkError("thread join failed".to_string())))
222 }),
223 Err(_) => tokio::runtime::Builder::new_current_thread()
224 .enable_all()
225 .build()
226 .map_err(|e| SkillError::NetworkError(e.to_string()))
227 .and_then(|rt| rt.block_on(fetch)),
228 }
229}
230
231pub fn load_skill_from_file(path: &Path) -> Result<Skill, SkillError> {
233 let content = std::fs::read_to_string(path)?;
234 let fallback_name = path
235 .file_stem()
236 .and_then(|s| s.to_str())
237 .unwrap_or("local-skill")
238 .to_string();
239 let mut skill = parse_skill(&content, &fallback_name)?;
240 skill.cache_path = Some(path.to_path_buf());
241 Ok(skill)
242}
243
244pub fn list_cached_skills(cache_dir: Option<&Path>) -> Vec<PathBuf> {
246 let skills_dir = cache_dir
247 .map(PathBuf::from)
248 .unwrap_or_else(default_skills_dir);
249
250 if !skills_dir.exists() {
251 return Vec::new();
252 }
253
254 let mut paths = Vec::new();
255 if let Ok(entries) = std::fs::read_dir(&skills_dir) {
256 for entry in entries.flatten() {
257 let path = entry.path();
258 if path.extension().and_then(|e| e.to_str()) == Some("md") {
259 paths.push(path);
260 }
261 }
262 }
263 paths.sort();
264 paths
265}
266
267#[cfg(test)]
268#[path = "skills_tests.rs"]
269mod tests;