Skip to main content

edgecrab_plugins/skill/
sync.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use sha2::{Digest, Sha256};
5
6use crate::error::PluginError;
7
8const MANIFEST_NAME: &str = ".bundled_manifest";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum BundledSyncStatus {
12    New,
13    Unchanged,
14    Customized,
15    DeletedByUser,
16    RemovedFromBundled,
17}
18
19#[derive(Debug, Default)]
20pub struct BundledSyncReport {
21    pub results: BTreeMap<String, BundledSyncStatus>,
22}
23
24pub fn bundled_skills_sync(
25    bundled_dir: &Path,
26    install_dir: &Path,
27) -> Result<BundledSyncReport, PluginError> {
28    std::fs::create_dir_all(install_dir)?;
29
30    let manifest_path = install_dir.join(MANIFEST_NAME);
31    let mut existing_manifest = read_manifest(&manifest_path)?;
32    let bundled = discover_bundled_skills(bundled_dir)?;
33    let mut report = BundledSyncReport::default();
34    let mut next_manifest = BTreeMap::new();
35
36    for (name, bundled_path) in &bundled {
37        let bundled_contents = std::fs::read_to_string(bundled_path)?;
38        let bundled_hash = hex_hash(&bundled_contents);
39        let destination = install_dir.join(name).join("SKILL.md");
40
41        let status = if destination.exists() {
42            let current_hash = hex_hash(&std::fs::read_to_string(&destination)?);
43            match existing_manifest.remove(name) {
44                Some(previous_hash) if previous_hash == current_hash => {
45                    if let Some(parent) = destination.parent() {
46                        std::fs::create_dir_all(parent)?;
47                    }
48                    std::fs::write(&destination, &bundled_contents)?;
49                    BundledSyncStatus::Unchanged
50                }
51                Some(_) => BundledSyncStatus::Customized,
52                None => BundledSyncStatus::Customized,
53            }
54        } else if existing_manifest.contains_key(name) {
55            existing_manifest.remove(name);
56            BundledSyncStatus::DeletedByUser
57        } else {
58            if let Some(parent) = destination.parent() {
59                std::fs::create_dir_all(parent)?;
60            }
61            std::fs::write(&destination, &bundled_contents)?;
62            BundledSyncStatus::New
63        };
64
65        next_manifest.insert(name.clone(), bundled_hash);
66        report.results.insert(name.clone(), status);
67    }
68
69    for removed in existing_manifest.keys() {
70        report
71            .results
72            .insert(removed.clone(), BundledSyncStatus::RemovedFromBundled);
73    }
74
75    write_manifest(&manifest_path, &next_manifest)?;
76    Ok(report)
77}
78
79fn discover_bundled_skills(root: &Path) -> Result<BTreeMap<String, PathBuf>, PluginError> {
80    let mut found = BTreeMap::new();
81    if !root.exists() {
82        return Ok(found);
83    }
84    for entry in std::fs::read_dir(root)? {
85        let entry = entry?;
86        let path = entry.path();
87        if !path.is_dir() {
88            continue;
89        }
90        let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
91            continue;
92        };
93        let skill_file = path.join("SKILL.md");
94        if skill_file.is_file() {
95            found.insert(name.to_string(), skill_file);
96        }
97    }
98    Ok(found)
99}
100
101fn read_manifest(path: &Path) -> Result<BTreeMap<String, String>, PluginError> {
102    if !path.exists() {
103        return Ok(BTreeMap::new());
104    }
105    let content = std::fs::read_to_string(path)?;
106    let mut entries = BTreeMap::new();
107    for line in content.lines() {
108        let Some((name, hash)) = line.split_once(':') else {
109            continue;
110        };
111        entries.insert(name.trim().to_string(), hash.trim().to_string());
112    }
113    Ok(entries)
114}
115
116fn write_manifest(path: &Path, entries: &BTreeMap<String, String>) -> Result<(), PluginError> {
117    let mut lines = Vec::with_capacity(entries.len());
118    for (name, hash) in entries {
119        lines.push(format!("{name}:{hash}"));
120    }
121    std::fs::write(path, lines.join("\n"))?;
122    Ok(())
123}
124
125fn hex_hash(content: &str) -> String {
126    let mut hasher = Sha256::new();
127    hasher.update(content.as_bytes());
128    format!("{:x}", hasher.finalize())
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn bundled_sync_covers_new_unchanged_customized_deleted_and_removed_cases() {
137        let temp = tempfile::tempdir().expect("tempdir");
138        let bundled_dir = temp.path().join("bundled");
139        let install_dir = temp.path().join("install");
140        std::fs::create_dir_all(&bundled_dir).expect("bundled dir");
141        std::fs::create_dir_all(&install_dir).expect("install dir");
142
143        write_skill(&bundled_dir.join("new-skill"), "new-skill", "New");
144        write_skill(&bundled_dir.join("unchanged"), "unchanged", "Unchanged");
145        write_skill(&bundled_dir.join("customized"), "customized", "Bundled");
146        write_skill(&bundled_dir.join("deleted"), "deleted", "Deleted");
147
148        write_skill(&install_dir.join("unchanged"), "unchanged", "Old");
149        write_skill(&install_dir.join("customized"), "customized", "Customized");
150        write_skill(&install_dir.join("removed"), "removed", "Removed");
151
152        let mut old_manifest = BTreeMap::new();
153        old_manifest.insert("unchanged".into(), hex_hash(&skill_doc("unchanged", "Old")));
154        old_manifest.insert(
155            "customized".into(),
156            hex_hash(&skill_doc("customized", "Bundled")),
157        );
158        old_manifest.insert("deleted".into(), hex_hash(&skill_doc("deleted", "Deleted")));
159        old_manifest.insert("removed".into(), hex_hash(&skill_doc("removed", "Removed")));
160        write_manifest(&install_dir.join(MANIFEST_NAME), &old_manifest).expect("manifest");
161
162        let report = bundled_skills_sync(&bundled_dir, &install_dir).expect("sync");
163
164        assert_eq!(report.results["new-skill"], BundledSyncStatus::New);
165        assert_eq!(report.results["unchanged"], BundledSyncStatus::Unchanged);
166        assert_eq!(report.results["customized"], BundledSyncStatus::Customized);
167        assert_eq!(report.results["deleted"], BundledSyncStatus::DeletedByUser);
168        assert_eq!(
169            report.results["removed"],
170            BundledSyncStatus::RemovedFromBundled
171        );
172    }
173
174    fn write_skill(dir: &Path, name: &str, description: &str) {
175        std::fs::create_dir_all(dir).expect("skill dir");
176        std::fs::write(dir.join("SKILL.md"), skill_doc(name, description)).expect("skill write");
177    }
178
179    fn skill_doc(name: &str, description: &str) -> String {
180        format!("---\nname: {name}\ndescription: {description}\n---\n\n# {description}\n\nBody.\n")
181    }
182}