use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub fn bundled_skills_dir() -> Option<PathBuf> {
if let Ok(env) = std::env::var("EDGECRAB_BUNDLED_SKILLS") {
let p = PathBuf::from(env);
if p.is_dir() {
return Some(p);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
let candidate = bin_dir.join("../..").join("skills");
if candidate.is_dir() {
return Some(candidate);
}
let flat = bin_dir.join("skills");
if flat.is_dir() {
return Some(flat);
}
}
}
let compile_time = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../skills");
if compile_time.is_dir() {
return Some(compile_time);
}
None
}
pub fn optional_skills_dir() -> Option<PathBuf> {
if let Ok(env) = std::env::var("EDGECRAB_OPTIONAL_SKILLS") {
let p = PathBuf::from(env);
if p.is_dir() {
return Some(p);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(bin_dir) = exe.parent() {
let candidate = bin_dir.join("../..").join("optional-skills");
if candidate.is_dir() {
return Some(candidate);
}
let flat = bin_dir.join("optional-skills");
if flat.is_dir() {
return Some(flat);
}
}
}
let compile_time = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../optional-skills");
if compile_time.is_dir() {
return Some(compile_time);
}
None
}
pub fn sync_on_startup() -> Option<SyncReport> {
let bundled_dir = bundled_skills_dir()?;
Some(sync_bundled_skills(&bundled_dir))
}
fn skills_dir() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
home.join(".edgecrab").join("skills")
}
fn manifest_path() -> PathBuf {
skills_dir().join(".bundled_manifest")
}
fn read_manifest() -> HashMap<String, String> {
let path = manifest_path();
match std::fs::read_to_string(&path) {
Ok(content) => {
let mut map = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((name, hash)) = line.split_once(':') {
map.insert(name.trim().to_string(), hash.trim().to_string());
} else {
map.insert(line.to_string(), String::new());
}
}
map
}
Err(_) => HashMap::new(),
}
}
fn write_manifest(entries: &HashMap<String, String>) {
let path = manifest_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let mut lines: Vec<String> = entries
.iter()
.map(|(name, hash)| format!("{name}:{hash}"))
.collect();
lines.sort();
let content = lines.join("\n") + "\n";
let _ = std::fs::write(&path, content);
}
fn dir_hash(directory: &Path) -> String {
use std::io::Read;
let mut hasher = md5::Context::new();
let mut paths: Vec<PathBuf> = Vec::new();
if let Ok(walker) = walkdir(directory) {
paths = walker;
paths.sort();
}
for fpath in &paths {
if let Ok(rel) = fpath.strip_prefix(directory) {
hasher.consume(rel.to_string_lossy().as_bytes());
if let Ok(mut f) = std::fs::File::open(fpath) {
let mut buf = Vec::new();
if f.read_to_end(&mut buf).is_ok() {
hasher.consume(&buf);
}
}
}
}
format!("{:x}", hasher.compute())
}
fn walkdir(dir: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
let mut files = Vec::new();
if !dir.is_dir() {
return Ok(files);
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
files.extend(walkdir(&path)?);
} else if path.is_file() {
files.push(path);
}
}
Ok(files)
}
fn discover_bundled_skills(bundled_dir: &Path) -> Vec<(String, PathBuf)> {
let mut skills = Vec::new();
if !bundled_dir.is_dir() {
return skills;
}
let mut stack: Vec<PathBuf> = vec![bundled_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if skill_md.is_file() {
let rel = path
.strip_prefix(bundled_dir)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
skills.push((rel, path));
} else {
stack.push(path);
}
}
}
skills
}
#[derive(Debug, Default)]
pub struct SyncReport {
pub added: Vec<String>,
pub updated: Vec<String>,
pub skipped_user_modified: Vec<String>,
pub skipped_deleted_by_user: Vec<String>,
pub removed_from_manifest: Vec<String>,
}
impl SyncReport {
pub fn summary(&self) -> String {
let mut parts = Vec::new();
if !self.added.is_empty() {
parts.push(format!("{} added", self.added.len()));
}
if !self.updated.is_empty() {
parts.push(format!("{} updated", self.updated.len()));
}
if !self.skipped_user_modified.is_empty() {
parts.push(format!(
"{} skipped (user-modified)",
self.skipped_user_modified.len()
));
}
if parts.is_empty() {
"No changes".into()
} else {
parts.join(", ")
}
}
}
pub fn sync_bundled_skills(bundled_dir: &Path) -> SyncReport {
let user_skills = skills_dir();
let _ = std::fs::create_dir_all(&user_skills);
let mut manifest = read_manifest();
let mut report = SyncReport::default();
let bundled = discover_bundled_skills(bundled_dir);
let bundled_names: std::collections::HashSet<String> =
bundled.iter().map(|(name, _)| name.clone()).collect();
for (name, bundled_path) in &bundled {
let user_path = user_skills.join(name);
let bundled_hash = dir_hash(bundled_path);
if let Some(origin_hash) = manifest.get(name) {
if !user_path.is_dir() {
report.skipped_deleted_by_user.push(name.clone());
continue;
}
if origin_hash.is_empty() {
copy_skill(bundled_path, &user_path);
manifest.insert(name.clone(), bundled_hash);
report.updated.push(name.clone());
continue;
}
let user_hash = dir_hash(&user_path);
if &user_hash == origin_hash {
if bundled_hash != *origin_hash {
copy_skill(bundled_path, &user_path);
manifest.insert(name.clone(), bundled_hash);
report.updated.push(name.clone());
}
} else {
report.skipped_user_modified.push(name.clone());
}
} else {
copy_skill(bundled_path, &user_path);
manifest.insert(name.clone(), bundled_hash);
report.added.push(name.clone());
}
}
let stale: Vec<String> = manifest
.keys()
.filter(|k| !bundled_names.contains(*k))
.cloned()
.collect();
for name in stale {
manifest.remove(&name);
report.removed_from_manifest.push(name);
}
write_manifest(&manifest);
report
}
fn copy_skill(src: &Path, dst: &Path) {
let _ = std::fs::create_dir_all(dst);
if let Ok(files) = walkdir(src) {
for file in files {
if let Ok(rel) = file.strip_prefix(src) {
let target = dst.join(rel);
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::copy(&file, &target);
}
}
}
}
mod md5 {
pub struct Context {
data: Vec<u8>,
}
pub struct Digest {
bytes: [u8; 16],
}
impl Context {
pub fn new() -> Self {
Self { data: Vec::new() }
}
pub fn consume(&mut self, input: &[u8]) {
self.data.extend_from_slice(input);
}
pub fn compute(self) -> Digest {
let mut hash: u64 = 0xcbf29ce484222325;
for &byte in &self.data {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
let mut hash2: u64 = 0x6c62272e07bb0142;
for &byte in self.data.iter().rev() {
hash2 ^= byte as u64;
hash2 = hash2.wrapping_mul(0x100000001b3);
}
let mut bytes = [0u8; 16];
bytes[..8].copy_from_slice(&hash.to_le_bytes());
bytes[8..].copy_from_slice(&hash2.to_le_bytes());
Digest { bytes }
}
}
impl std::fmt::LowerHex for Digest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for byte in &self.bytes {
write!(f, "{:02x}", byte)?;
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn manifest_roundtrip() {
let mut entries: HashMap<String, String> = HashMap::new();
entries.insert("skill-a".into(), "abc123".into());
entries.insert("skill-b".into(), "def456".into());
let mut lines: Vec<String> = entries
.iter()
.map(|(name, hash)| format!("{name}:{hash}"))
.collect();
lines.sort();
let content = lines.join("\n") + "\n";
let mut parsed = HashMap::new();
for line in content.lines() {
if let Some((name, hash)) = line.split_once(':') {
parsed.insert(name.to_string(), hash.to_string());
}
}
assert_eq!(parsed.len(), 2);
assert_eq!(parsed["skill-a"], "abc123");
}
#[test]
fn dir_hash_consistent() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
let h1 = dir_hash(dir.path());
let h2 = dir_hash(dir.path());
assert_eq!(h1, h2, "same contents should produce same hash");
}
#[test]
fn dir_hash_changes_on_content_change() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("test.txt"), "hello").unwrap();
let h1 = dir_hash(dir.path());
std::fs::write(dir.path().join("test.txt"), "world").unwrap();
let h2 = dir_hash(dir.path());
assert_ne!(h1, h2, "different contents should produce different hash");
}
#[test]
fn sync_adds_new_skills() {
let bundled = TempDir::new().unwrap();
let skill_a = bundled.path().join("skill-a");
std::fs::create_dir_all(&skill_a).unwrap();
std::fs::write(skill_a.join("SKILL.md"), "# Skill A").unwrap();
let target = TempDir::new().unwrap();
let report = sync_to_dir(bundled.path(), target.path());
assert_eq!(report.added.len(), 1);
assert_eq!(report.added[0], "skill-a");
}
fn sync_to_dir(bundled_dir: &Path, user_dir: &Path) -> SyncReport {
let _ = std::fs::create_dir_all(user_dir);
let bundled = discover_bundled_skills(bundled_dir);
let mut report = SyncReport::default();
for (name, bundled_path) in &bundled {
let user_path = user_dir.join(name);
copy_skill(bundled_path, &user_path);
report.added.push(name.clone());
}
report
}
}