1use std::path::{Path, PathBuf};
12
13use super::change::{Action, Change, read};
14use crate::harness::{Harness, Level};
15
16pub const FILES: [(&str, &str); 5] = [
18 ("SKILL.md", include_str!("../../assets/skills/borg/SKILL.md")),
19 ("references/collectives.md", include_str!("../../assets/skills/borg/references/collectives.md")),
20 ("references/team.md", include_str!("../../assets/skills/borg/references/team.md")),
21 ("references/vectors.md", include_str!("../../assets/skills/borg/references/vectors.md")),
22 ("references/troubleshooting.md", include_str!("../../assets/skills/borg/references/troubleshooting.md")),
23];
24
25const RETIRED: [(&str, &[&str]); 1] = [(
29 "references/hive.md",
30 &[
31 "e489258ae65ce54c7b30b8d30e18754424946420a39cb03e45daa1cadb602380",
32 "e5f0eff3bddbfc883290fbefc781bcee3286562f06d95a64496514c97b686d94",
33 "c305e7b93c700d7f38c9b7968116fc4ece56a4f5b2f05f6bc47dadeecab84741",
34 "15f4dc8013f7584988d7ce035af1589917ad9e2b52f744792b5a42973f41c939",
35 "d11ef6672729f9d669a708e7439ef653da14eed88e908dcaed7d2dbb94e65fbb",
36 "23e607385e63f7b202e6bebf3dc8617023ec632f88958073354e9d6c0629be10",
37 ],
38)];
39
40fn retired(dir: &Path) -> Vec<(PathBuf, String)> {
42 RETIRED
43 .iter()
44 .filter_map(|(rel, known)| {
45 let path = dir.join(rel);
46 let text = read(&path).ok().flatten()?;
47 known.contains(&blake3::hash(text.as_bytes()).to_hex().as_str()).then_some((path, text))
48 })
49 .collect()
50}
51
52pub fn dir(h: &dyn Harness, home: &Path, var: &dyn Fn(&str) -> Option<PathBuf>) -> Option<PathBuf> {
57 let up = 1 + usize::from(!h.wiring().adapter().is_empty());
58 let dir = h.target(home, var).ancestors().nth(up)?.join("skills/borg");
59 (!h.hidden()).then_some(dir)
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub enum State {
65 Absent,
67 Current,
69 Stale,
71 Linked,
73 Unreadable(String),
75}
76
77fn linked(dir: &Path) -> bool {
78 std::fs::symlink_metadata(dir).is_ok_and(|m| m.file_type().is_symlink())
79}
80
81fn current(dir: &Path) -> Result<Vec<Option<String>>, String> {
83 FILES.iter().map(|(rel, _)| read(&dir.join(rel)).map_err(|e| format!("{}: cannot read ({e}); nothing was changed", dir.join(rel).display()))).collect()
84}
85
86pub fn plan(action: Action, dir: Option<&Path>) -> Result<(Vec<Change>, Vec<String>), String> {
92 let Some(dir) = dir else { return Ok((Vec::new(), Vec::new())) };
93 if linked(dir) {
94 return Ok((Vec::new(), vec![format!("{}: left alone, a symlink (yours); the borg skill is not managed there", dir.display())]));
95 }
96 let befores = current(dir)?;
97 let mut changes = Vec::new();
98 let mut foreign = Vec::new();
99 for ((rel, text), before) in FILES.iter().zip(befores) {
100 let path = dir.join(rel);
101 let ours = before.as_deref() == Some(*text);
102 match action {
103 Action::Install if !ours => changes.push(Change { path, before, after: Some((*text).to_string()), through_links: false, backup: true }),
104 Action::Uninstall if ours => changes.push(Change { path, before, after: None, through_links: false, backup: false }),
105 Action::Uninstall if before.is_some() => foreign.push(path.display().to_string()),
106 _ => {}
107 }
108 }
109 for (path, text) in retired(dir) {
110 changes.push(Change { path, before: Some(text), after: None, through_links: false, backup: false });
111 }
112 let notes = foreign.into_iter().map(|p| format!("{p}: left alone, its content is not this borg's skill (yours, or an older borg's)")).collect();
113 Ok((changes, notes))
114}
115
116pub fn tidy(dir: &Path) {
119 if linked(dir) {
120 return;
121 }
122 let _ = std::fs::remove_dir(dir.join("references"));
123 let _ = std::fs::remove_dir(dir);
124}
125
126#[must_use]
128pub fn state(dir: &Path) -> State {
129 if linked(dir) {
130 return State::Linked;
131 }
132 match current(dir) {
133 Err(e) => State::Unreadable(e),
134 Ok(found) if found.iter().all(Option::is_none) => State::Absent,
135 Ok(found) => {
136 let same = FILES.iter().zip(&found).all(|((_, text), f)| f.as_deref() == Some(*text));
137 if same && retired(dir).is_empty() { State::Current } else { State::Stale }
138 }
139 }
140}
141
142#[must_use]
144pub fn status(name: &str, dir: Option<&Path>) -> Option<String> {
145 let dir = dir?;
146 Some(match state(dir) {
147 State::Absent => "skill not installed".to_string(),
148 State::Current => format!("skill installed ({})", dir.display()),
149 State::Stale => format!("skill STALE: {} differs from this borg's (borg install {name})", dir.display()),
150 State::Linked => format!("skill: {} is a symlink (yours)", dir.display()),
151 State::Unreadable(e) => format!("skill UNREADABLE: {e}"),
152 })
153}
154
155#[must_use]
158pub fn checks(name: &str, dir: Option<&Path>, wired: bool) -> Vec<(Level, String)> {
159 let Some(dir) = dir else { return Vec::new() };
160 let fix = format!("borg install {name} --apply");
161 match state(dir) {
162 State::Absent if wired => vec![(Level::Warn, format!("{name}: the borg skill is missing at {} ({fix})", dir.display()))],
163 State::Absent => Vec::new(),
164 State::Current => vec![(Level::Ok, format!("{name}: borg skill installed at {}", dir.display()))],
165 State::Stale => vec![(Level::Warn, format!("{name}: the borg skill at {} differs from this borg's, older or edited ({fix})", dir.display()))],
166 State::Linked => vec![(Level::Ok, format!("{name}: the borg skill at {} is a symlink (yours); borg leaves it alone", dir.display()))],
167 State::Unreadable(e) => vec![(Level::Warn, format!("{name}: borg skill {e}"))],
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::install::change::apply;
175
176 fn tmp(name: &str) -> std::path::PathBuf {
177 let d = std::env::temp_dir().join(format!("borg-skill-{name}-{}", std::process::id()));
178 let _ = std::fs::remove_dir_all(&d);
179 std::fs::create_dir_all(&d).unwrap();
180 d
181 }
182
183 fn run(action: Action, dir: &Path, stamp: &str) -> (usize, Vec<String>) {
184 let (changes, notes) = plan(action, Some(dir)).unwrap();
185 for c in &changes {
186 apply(c, stamp).unwrap();
187 }
188 tidy(dir);
189 (changes.len(), notes)
190 }
191
192 #[test]
193 fn the_skill_lands_in_each_harness_own_config_directory() {
194 let home = Path::new("/h");
195 for h in crate::harness::visible() {
196 let d = dir(h, home, &|_| None).unwrap();
197 assert!(d.starts_with(home) && d.ends_with("skills/borg"), "{}", d.display());
198 assert_eq!(dir(h, home, &|_| Some(PathBuf::from("/v"))).unwrap(), Path::new("/v/skills/borg"), "{}", h.name());
200 }
201 assert!(crate::harness::ALL.iter().filter(|h| h.hidden()).all(|h| dir(*h, home, &|_| None).is_none()));
202 }
203
204 #[test]
205 fn the_skill_names_itself_and_says_when_to_load() {
206 let (name, skill) = FILES[0];
207 assert_eq!(name, "SKILL.md");
208 let front = skill.strip_prefix("---\n").and_then(|s| s.split_once("\n---\n")).map(|(f, _)| f).expect("frontmatter first");
209 let front: std::collections::BTreeMap<String, String> = serde_norway::from_str(front).expect("the frontmatter is YAML");
211 assert_eq!(front.get("name").map(String::as_str), Some("borg"), "{front:?}");
212 let desc = front.get("description").expect("a description");
213 assert!(desc.len() <= 1024, "opencode's limit");
214 for word in ["borg", "collective", "lesson", "drone", "queen", "mesh", "vectors"] {
215 assert!(desc.contains(word), "the description triggers on {word}");
216 }
217 for (rel, _) in &FILES[1..] {
218 assert!(skill.contains(rel), "SKILL.md links {rel}");
219 }
220 }
221
222 #[test]
223 fn install_copies_every_file_once_and_uninstall_removes_exactly_them() {
224 let home = tmp("cycle");
225 let dir = home.join("skills/borg");
226 assert_eq!(state(&dir), State::Absent);
227 assert_eq!(run(Action::Install, &dir, "a").0, FILES.len());
228 assert_eq!(state(&dir), State::Current);
229 assert_eq!(run(Action::Install, &dir, "b").0, 0, "idempotent");
230 assert_eq!(run(Action::Uninstall, &dir, "c").0, FILES.len());
231 assert!(!dir.exists(), "the empty skill directory goes too");
232 assert!(home.join("skills").is_dir(), "the harness's skills directory stays");
233 assert_eq!(run(Action::Uninstall, &dir, "d").0, 0);
234 }
235
236 #[test]
237 fn an_edited_file_is_stale_replaced_with_a_backup_and_never_uninstalled() {
238 let dir = tmp("edited").join("borg");
239 run(Action::Install, &dir, "a");
240 std::fs::write(dir.join("references/team.md"), "my notes").unwrap();
241 assert_eq!(state(&dir), State::Stale);
242 assert!(checks("x", Some(&dir), true)[0].1.contains("borg install x --apply"), "the harness's own fix, not all");
243 let (n, notes) = run(Action::Uninstall, &dir, "u");
244 assert_eq!((n, notes.len()), (FILES.len() - 1, 1), "{notes:?}");
245 assert_eq!(std::fs::read_to_string(dir.join("references/team.md")).unwrap(), "my notes", "left alone");
246 assert_eq!(run(Action::Install, &dir, "i").0, FILES.len());
247 assert_eq!(std::fs::read_to_string(dir.join("references/team.md.borg-backup-i")).unwrap(), "my notes", "backed up");
248 assert_eq!(state(&dir), State::Current);
249 }
250
251 #[test]
252 fn an_older_borgs_retired_file_goes_and_an_edited_one_stays() {
253 let dir = tmp("retired").join("borg");
254 run(Action::Install, &dir, "a");
255 let old = dir.join("references/hive.md");
256 let shipped = std::process::Command::new("git")
257 .args(["show", "188c262:crates/borg/assets/skills/borg/references/hive.md"])
258 .current_dir(env!("CARGO_MANIFEST_DIR"))
259 .output()
260 .ok()
261 .filter(|o| o.status.success());
262 if let Some(o) = shipped {
263 std::fs::write(&old, &o.stdout).unwrap();
264 assert_eq!(state(&dir), State::Stale, "a leftover of an older borg asks for a reinstall");
265 assert_eq!(run(Action::Install, &dir, "b").0, 1);
266 assert!(!old.exists());
267 }
268 std::fs::write(&old, "my notes").unwrap();
269 assert_eq!(state(&dir), State::Current, "the engineer's own file is not borg's");
270 assert_eq!(run(Action::Install, &dir, "c").0, 0);
271 assert_eq!(std::fs::read_to_string(&old).unwrap(), "my notes");
272 }
273
274 #[cfg(unix)]
275 #[test]
276 fn symlinks_a_linked_file_is_replaced_and_a_linked_directory_left_alone() {
277 let home = tmp("links");
278 let outside = home.join("checkout.md");
279 std::fs::write(&outside, "checkout").unwrap();
280 let dir = home.join("borg");
281 std::fs::create_dir_all(&dir).unwrap();
282 std::os::unix::fs::symlink(&outside, dir.join("SKILL.md")).unwrap();
283 run(Action::Install, &dir, "a");
284 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "checkout", "not written through");
285 assert_eq!(state(&dir), State::Current);
286
287 let linked_dir = home.join("linked");
288 std::os::unix::fs::symlink(home.join("elsewhere"), &linked_dir).unwrap();
289 let (n, notes) = run(Action::Install, &linked_dir, "b");
290 assert_eq!((n, notes.len()), (0, 1));
291 assert!(!home.join("elsewhere").exists());
292 assert_eq!(state(&linked_dir), State::Linked);
293 }
294
295 #[test]
296 fn doctor_warns_on_a_missing_skill_only_where_borg_is_wired() {
297 let dir = tmp("doctor").join("borg");
298 assert!(checks("x", Some(&dir), false).is_empty());
299 assert_eq!(checks("x", Some(&dir), true)[0].0, Level::Warn);
300 assert!(checks("x", None, true).is_empty(), "a harness without skills");
301 assert_eq!(status("x", None), None);
302 run(Action::Install, &dir, "a");
303 assert_eq!(checks("x", Some(&dir), true)[0].0, Level::Ok);
304 assert!(status("x", Some(&dir)).unwrap().starts_with("skill installed"));
305 }
306}