edgecrab_plugins/skill/
sync.rs1use 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}