use std::collections::HashMap;
use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
use thiserror::Error;
use tracing::debug;
#[derive(Debug, Error)]
pub enum SkillError {
#[error("invalid URL: {0}")]
InvalidUrl(String),
#[error("network error: {0}")]
NetworkError(String),
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
#[error("skill parse error: {0}")]
ParseError(String),
#[error("URL scheme must be https: {0}")]
InsecureUrl(String),
}
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub content: String,
pub source_url: Option<String>,
pub cache_path: Option<PathBuf>,
pub sections: HashMap<String, String>,
}
fn default_skills_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".opendev")
.join("skills")
}
fn url_to_cache_filename(url: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(url.as_bytes());
let hash = hasher.finalize();
let hex: String = hash.iter().take(8).map(|b| format!("{b:02x}")).collect();
format!("{hex}.md")
}
pub fn parse_skill(content: &str, fallback_name: &str) -> Result<Skill, SkillError> {
if content.trim().is_empty() {
return Err(SkillError::ParseError("skill content is empty".to_string()));
}
let mut name = fallback_name.to_string();
let mut sections: HashMap<String, String> = HashMap::new();
let mut current_section = String::new();
let mut current_body = String::new();
for line in content.lines() {
if let Some(heading) = line.strip_prefix("# ") {
if name == fallback_name && !heading.trim().is_empty() {
name = heading.trim().to_string();
}
if !current_section.is_empty() {
sections.insert(current_section.clone(), current_body.trim().to_string());
}
current_section = heading.trim().to_string();
current_body.clear();
} else if let Some(heading) = line.strip_prefix("## ") {
if !current_section.is_empty() {
sections.insert(current_section.clone(), current_body.trim().to_string());
}
current_section = heading.trim().to_string();
current_body.clear();
} else {
current_body.push_str(line);
current_body.push('\n');
}
}
if !current_section.is_empty() {
sections.insert(current_section, current_body.trim().to_string());
}
Ok(Skill {
name,
content: content.to_string(),
source_url: None,
cache_path: None,
sections,
})
}
pub fn load_skill_from_url(url: &str) -> Result<Skill, SkillError> {
load_skill_from_url_with_options(url, None, false)
}
pub fn load_skill_from_url_with_options(
url: &str,
cache_dir: Option<&Path>,
force_refresh: bool,
) -> Result<Skill, SkillError> {
if !url.starts_with("https://") {
return Err(SkillError::InsecureUrl(url.to_string()));
}
if !url.contains('.') || url.len() < 12 {
return Err(SkillError::InvalidUrl(url.to_string()));
}
let skills_dir = cache_dir
.map(PathBuf::from)
.unwrap_or_else(default_skills_dir);
let cache_filename = url_to_cache_filename(url);
let cache_path = skills_dir.join(&cache_filename);
if !force_refresh && cache_path.exists() {
debug!("Loading cached skill from {:?}", cache_path);
let content = std::fs::read_to_string(&cache_path)?;
let fallback_name = extract_name_from_url(url);
let mut skill = parse_skill(&content, &fallback_name)?;
skill.source_url = Some(url.to_string());
skill.cache_path = Some(cache_path);
return Ok(skill);
}
debug!("Fetching skill from {}", url);
let content = fetch_url_content(url)?;
std::fs::create_dir_all(&skills_dir)?;
std::fs::write(&cache_path, &content)?;
debug!("Cached skill to {:?}", cache_path);
let fallback_name = extract_name_from_url(url);
let mut skill = parse_skill(&content, &fallback_name)?;
skill.source_url = Some(url.to_string());
skill.cache_path = Some(cache_path);
Ok(skill)
}
fn extract_name_from_url(url: &str) -> String {
url.rsplit('/')
.next()
.unwrap_or("remote-skill")
.trim_end_matches(".md")
.replace(['-', '_'], " ")
}
fn fetch_url_content(url: &str) -> Result<String, SkillError> {
let url_owned = url.to_string();
let fetch = async move {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.user_agent("opendev-rust/0.1.0")
.build()
.map_err(|e| SkillError::NetworkError(e.to_string()))?;
let resp = client
.get(&url_owned)
.send()
.await
.map_err(|e| SkillError::NetworkError(e.to_string()))?;
if !resp.status().is_success() {
return Err(SkillError::NetworkError(format!(
"HTTP {} for {}",
resp.status(),
url_owned
)));
}
resp.text()
.await
.map_err(|e| SkillError::NetworkError(e.to_string()))
};
match tokio::runtime::Handle::try_current() {
Ok(_handle) => std::thread::scope(|s| {
s.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SkillError::NetworkError(e.to_string()))
.and_then(|rt| rt.block_on(fetch))
})
.join()
.unwrap_or_else(|_| Err(SkillError::NetworkError("thread join failed".to_string())))
}),
Err(_) => tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SkillError::NetworkError(e.to_string()))
.and_then(|rt| rt.block_on(fetch)),
}
}
pub fn load_skill_from_file(path: &Path) -> Result<Skill, SkillError> {
let content = std::fs::read_to_string(path)?;
let fallback_name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("local-skill")
.to_string();
let mut skill = parse_skill(&content, &fallback_name)?;
skill.cache_path = Some(path.to_path_buf());
Ok(skill)
}
pub fn list_cached_skills(cache_dir: Option<&Path>) -> Vec<PathBuf> {
let skills_dir = cache_dir
.map(PathBuf::from)
.unwrap_or_else(default_skills_dir);
if !skills_dir.exists() {
return Vec::new();
}
let mut paths = Vec::new();
if let Ok(entries) = std::fs::read_dir(&skills_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("md") {
paths.push(path);
}
}
}
paths.sort();
paths
}
#[cfg(test)]
#[path = "skills_tests.rs"]
mod tests;