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 assert!(front.lines().any(|l| l == "name: borg"), "{front}");
210 let desc = front.lines().find_map(|l| l.strip_prefix("description: ")).expect("a description");
211 assert!(desc.len() <= 1024, "opencode's limit");
212 for word in ["borg", "collective", "lesson", "drone", "queen", "mesh", "vectors"] {
213 assert!(desc.contains(word), "the description triggers on {word}");
214 }
215 for (rel, _) in &FILES[1..] {
216 assert!(skill.contains(rel), "SKILL.md links {rel}");
217 }
218 }
219
220 #[test]
221 fn install_copies_every_file_once_and_uninstall_removes_exactly_them() {
222 let home = tmp("cycle");
223 let dir = home.join("skills/borg");
224 assert_eq!(state(&dir), State::Absent);
225 assert_eq!(run(Action::Install, &dir, "a").0, FILES.len());
226 assert_eq!(state(&dir), State::Current);
227 assert_eq!(run(Action::Install, &dir, "b").0, 0, "idempotent");
228 assert_eq!(run(Action::Uninstall, &dir, "c").0, FILES.len());
229 assert!(!dir.exists(), "the empty skill directory goes too");
230 assert!(home.join("skills").is_dir(), "the harness's skills directory stays");
231 assert_eq!(run(Action::Uninstall, &dir, "d").0, 0);
232 }
233
234 #[test]
235 fn an_edited_file_is_stale_replaced_with_a_backup_and_never_uninstalled() {
236 let dir = tmp("edited").join("borg");
237 run(Action::Install, &dir, "a");
238 std::fs::write(dir.join("references/team.md"), "my notes").unwrap();
239 assert_eq!(state(&dir), State::Stale);
240 assert!(checks("x", Some(&dir), true)[0].1.contains("borg install x --apply"), "the harness's own fix, not all");
241 let (n, notes) = run(Action::Uninstall, &dir, "u");
242 assert_eq!((n, notes.len()), (FILES.len() - 1, 1), "{notes:?}");
243 assert_eq!(std::fs::read_to_string(dir.join("references/team.md")).unwrap(), "my notes", "left alone");
244 assert_eq!(run(Action::Install, &dir, "i").0, FILES.len());
245 assert_eq!(std::fs::read_to_string(dir.join("references/team.md.borg-backup-i")).unwrap(), "my notes", "backed up");
246 assert_eq!(state(&dir), State::Current);
247 }
248
249 #[test]
250 fn an_older_borgs_retired_file_goes_and_an_edited_one_stays() {
251 let dir = tmp("retired").join("borg");
252 run(Action::Install, &dir, "a");
253 let old = dir.join("references/hive.md");
254 let shipped = std::process::Command::new("git")
255 .args(["show", "188c262:crates/borg/assets/skills/borg/references/hive.md"])
256 .current_dir(env!("CARGO_MANIFEST_DIR"))
257 .output()
258 .ok()
259 .filter(|o| o.status.success());
260 if let Some(o) = shipped {
261 std::fs::write(&old, &o.stdout).unwrap();
262 assert_eq!(state(&dir), State::Stale, "a leftover of an older borg asks for a reinstall");
263 assert_eq!(run(Action::Install, &dir, "b").0, 1);
264 assert!(!old.exists());
265 }
266 std::fs::write(&old, "my notes").unwrap();
267 assert_eq!(state(&dir), State::Current, "the engineer's own file is not borg's");
268 assert_eq!(run(Action::Install, &dir, "c").0, 0);
269 assert_eq!(std::fs::read_to_string(&old).unwrap(), "my notes");
270 }
271
272 #[cfg(unix)]
273 #[test]
274 fn symlinks_a_linked_file_is_replaced_and_a_linked_directory_left_alone() {
275 let home = tmp("links");
276 let outside = home.join("checkout.md");
277 std::fs::write(&outside, "checkout").unwrap();
278 let dir = home.join("borg");
279 std::fs::create_dir_all(&dir).unwrap();
280 std::os::unix::fs::symlink(&outside, dir.join("SKILL.md")).unwrap();
281 run(Action::Install, &dir, "a");
282 assert_eq!(std::fs::read_to_string(&outside).unwrap(), "checkout", "not written through");
283 assert_eq!(state(&dir), State::Current);
284
285 let linked_dir = home.join("linked");
286 std::os::unix::fs::symlink(home.join("elsewhere"), &linked_dir).unwrap();
287 let (n, notes) = run(Action::Install, &linked_dir, "b");
288 assert_eq!((n, notes.len()), (0, 1));
289 assert!(!home.join("elsewhere").exists());
290 assert_eq!(state(&linked_dir), State::Linked);
291 }
292
293 #[test]
294 fn doctor_warns_on_a_missing_skill_only_where_borg_is_wired() {
295 let dir = tmp("doctor").join("borg");
296 assert!(checks("x", Some(&dir), false).is_empty());
297 assert_eq!(checks("x", Some(&dir), true)[0].0, Level::Warn);
298 assert!(checks("x", None, true).is_empty(), "a harness without skills");
299 assert_eq!(status("x", None), None);
300 run(Action::Install, &dir, "a");
301 assert_eq!(checks("x", Some(&dir), true)[0].0, Level::Ok);
302 assert!(status("x", Some(&dir)).unwrap().starts_with("skill installed"));
303 }
304}