1use crate::error::{BbError, Result};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5
6pub struct Skill {
11 pub name: &'static str,
12 pub summary: &'static str,
15 pub content: &'static str,
16}
17
18pub const SKILLS: [Skill; 4] = [
19 Skill {
20 name: "bitbucket-cloud",
21 summary: "read, review and comment on Bitbucket Cloud pull requests",
22 content: include_str!("../.agents/skills/bitbucket-cloud/SKILL.md"),
23 },
24 Skill {
25 name: "bbc-daily-brief",
26 summary: "a ranked morning brief of the pull requests waiting on you",
27 content: include_str!("../.agents/skills/bbc-daily-brief/SKILL.md"),
28 },
29 Skill {
30 name: "bbc-open-pr",
31 summary: "open a pull request: reviewer suggestions from git history",
32 content: include_str!("../.agents/skills/bbc-open-pr/SKILL.md"),
33 },
34 Skill {
35 name: "bbc-report-bug",
36 summary: "file a bb bug upstream: reproduce, redact, ask, then gh",
37 content: include_str!("../.agents/skills/bbc-report-bug/SKILL.md"),
38 },
39];
40
41pub fn skill_by_name(name: &str) -> Option<&'static Skill> {
42 SKILLS.iter().find(|s| s.name == name)
43}
44
45fn default_skill_name() -> String {
48 "bitbucket-cloud".to_string()
49}
50
51pub fn content_hash(bytes: &[u8]) -> String {
52 let mut hasher = Sha256::new();
53 hasher.update(bytes);
54 format!("{:x}", hasher.finalize())
55}
56
57pub fn tracked_version_differs(entries: &[Entry]) -> bool {
62 entries
63 .iter()
64 .any(|e| e.version != env!("CARGO_PKG_VERSION"))
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum Agent {
69 Agents,
71 Claude,
73}
74
75impl Agent {
76 pub fn as_str(self) -> &'static str {
77 match self {
78 Self::Agents => "agents",
79 Self::Claude => "claude",
80 }
81 }
82
83 pub fn all() -> [Agent; 2] {
84 [Agent::Agents, Agent::Claude]
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Entry {
90 pub path: PathBuf,
91 pub agent: String,
92 pub kind: String,
95 pub sha256: String,
98 pub version: String,
99 #[serde(default = "default_skill_name")]
100 pub skill: String,
101 #[serde(default = "default_created")]
111 pub created: bool,
112}
113
114fn default_created() -> bool {
115 true
116}
117
118pub fn state_path() -> PathBuf {
119 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
120 if !xdg.is_empty() {
121 return PathBuf::from(xdg).join("bb").join("skills.json");
122 }
123 }
124 let home = std::env::var_os("HOME").unwrap_or_default();
125 PathBuf::from(home)
126 .join(".config")
127 .join("bb")
128 .join("skills.json")
129}
130
131pub fn load_state() -> (Vec<Entry>, Option<String>) {
135 let path = state_path();
136 let raw = match std::fs::read_to_string(&path) {
137 Ok(text) => text,
138 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return (Vec::new(), None),
139 Err(err) => {
140 return (
141 Vec::new(),
142 Some(format!("could not read {}: {err}", path.display())),
143 )
144 }
145 };
146 if raw.trim().is_empty() {
147 return (Vec::new(), None);
148 }
149 match serde_json::from_str::<Vec<Entry>>(&raw) {
150 Ok(entries) => (entries, None),
151 Err(err) => (
152 Vec::new(),
153 Some(format!("ignoring unreadable {}: {err}", path.display())),
154 ),
155 }
156}
157
158pub fn save_state(entries: &[Entry]) -> Result<()> {
163 let path = state_path();
164 let parent = path.parent().ok_or_else(|| {
165 BbError::Config(format!(
166 "state path {} has no parent directory",
167 path.display()
168 ))
169 })?;
170 std::fs::create_dir_all(parent).map_err(BbError::Io)?;
171 let json = serde_json::to_string_pretty(entries)?;
172 let tmp = parent.join(format!(
173 ".skills.json.tmp.{}.{}",
174 std::process::id(),
175 std::time::SystemTime::now()
176 .duration_since(std::time::UNIX_EPOCH)
177 .map(|d| d.as_nanos())
178 .unwrap_or(0)
179 ));
180 std::fs::write(&tmp, json).map_err(BbError::Io)?;
181 std::fs::rename(&tmp, &path).map_err(BbError::Io)?;
182 Ok(())
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum Action {
187 Installed,
188 Refreshed,
189 Unchanged,
190 SkippedModified,
191 Pruned,
194 Failed,
199}
200
201impl Action {
202 pub fn as_str(self) -> &'static str {
203 match self {
204 Self::Installed => "installed",
205 Self::Refreshed => "refreshed",
206 Self::Unchanged => "unchanged",
207 Self::SkippedModified => "skipped_modified",
208 Self::Pruned => "pruned",
209 Self::Failed => "failed",
210 }
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum MissingPolicy {
221 Restore,
222 Preserve,
223}
224
225#[derive(Debug, Clone)]
226pub struct Outcome {
227 pub path: PathBuf,
228 pub agent: String,
229 pub skill: String,
230 pub action: Action,
231}
232
233pub fn skill_file(root: &Path, agent: Agent, skill: &Skill) -> PathBuf {
235 let base = match agent {
236 Agent::Agents => root.join(".agents").join("skills"),
237 Agent::Claude => root.join(".claude").join("skills"),
238 };
239 base.join(skill.name).join("SKILL.md")
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum State {
244 Current,
245 Stale,
246 Modified,
247 Missing,
248}
249
250impl State {
251 pub fn as_str(self) -> &'static str {
252 match self {
253 Self::Current => "current",
254 Self::Stale => "stale",
255 Self::Modified => "modified",
256 Self::Missing => "missing",
257 }
258 }
259}
260
261#[derive(Debug, Clone)]
262pub struct StatusRow {
263 pub path: PathBuf,
264 pub agent: String,
265 pub skill: String,
266 pub state: State,
267}
268
269fn is_shaped_like_a_skill_path(path: &Path) -> bool {
278 let mut components: Vec<_> = path.components().collect();
279 let Some(file) = components.pop() else {
280 return false;
281 };
282 if file.as_os_str() != "SKILL.md" {
283 return false;
284 }
285 let Some(skill_dir) = components.pop() else {
286 return false;
287 };
288 if skill_by_name(&skill_dir.as_os_str().to_string_lossy()).is_none() {
289 return false;
290 }
291 let Some(skills_dir) = components.pop() else {
292 return false;
293 };
294 if skills_dir.as_os_str() != "skills" {
295 return false;
296 }
297 matches!(
298 components.pop().map(|c| c.as_os_str().to_owned()),
299 Some(agents_dir) if agents_dir == ".agents" || agents_dir == ".claude"
300 )
301}
302
303fn ancestor_is_definitely_gone(ancestor: &Path) -> bool {
310 matches!(
311 std::fs::symlink_metadata(ancestor),
312 Err(e) if e.kind() == std::io::ErrorKind::NotFound
313 )
314}
315
316fn state_of(entry: &Entry, wanted: &str) -> State {
317 match std::fs::read(&entry.path) {
318 Err(_) => State::Missing,
319 Ok(bytes) => {
320 let actual = content_hash(&bytes);
321 if actual == wanted {
322 State::Current
323 } else if actual == entry.sha256 {
324 State::Stale
325 } else {
326 State::Modified
327 }
328 }
329 }
330}
331
332pub fn status() -> (Vec<StatusRow>, Option<String>) {
333 let (entries, warning) = load_state();
334 let rows = entries
335 .iter()
336 .map(|e| {
337 let state = match skill_by_name(&e.skill) {
344 Some(skill) => state_of(e, &content_hash(skill.content.as_bytes())),
345 None => State::Modified,
346 };
347 StatusRow {
348 path: e.path.clone(),
349 agent: e.agent.clone(),
350 skill: e.skill.clone(),
351 state,
352 }
353 })
354 .collect();
355 (rows, warning)
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub enum RemovalOutcome {
363 Removed,
364 RefusedModified,
365 RefusedNotWritten,
368 RefusedUnsafePath,
369 Absent,
370}
371
372impl RemovalOutcome {
373 pub fn as_str(self) -> &'static str {
374 match self {
375 Self::Removed => "removed",
376 Self::RefusedModified => "refused_modified",
377 Self::RefusedNotWritten => "refused_not_written",
378 Self::RefusedUnsafePath => "refused_unsafe_path",
379 Self::Absent => "absent",
380 }
381 }
382}
383
384fn parent_is_symlink(path: &Path) -> bool {
391 path.parent()
392 .and_then(|p| std::fs::symlink_metadata(p).ok())
393 .map(|m| m.file_type().is_symlink())
394 .unwrap_or(false)
395}
396
397pub fn uninstall(
400 root: Option<&Path>,
401 skills: &[&'static Skill],
402 force: bool,
403) -> Result<Vec<(PathBuf, String, RemovalOutcome)>> {
404 let (entries, warning) = load_state();
405 if let Some(warning) = warning {
406 crate::output::warn(&warning);
407 }
408 let in_scope_skill_names: Vec<&str> = skills.iter().map(|s| s.name).collect();
409 let mut results = Vec::new();
410 let mut keep = Vec::new();
411
412 for entry in entries {
413 let in_scope = root.is_none_or(|r| entry.path.starts_with(r))
414 && in_scope_skill_names.contains(&entry.skill.as_str());
415 if !in_scope {
416 keep.push(entry);
417 continue;
418 }
419 if !is_shaped_like_a_skill_path(&entry.path) {
420 crate::output::warn(&format!(
421 "refusing to touch {} — does not look like a skill path bb would have written",
422 entry.path.display()
423 ));
424 results.push((
425 entry.path.clone(),
426 entry.skill.clone(),
427 RemovalOutcome::RefusedUnsafePath,
428 ));
429 keep.push(entry);
430 continue;
431 }
432 let wanted = skill_by_name(&entry.skill)
433 .map(|s| content_hash(s.content.as_bytes()))
434 .unwrap_or_default();
435 let is_symlinked_dir = entry.kind == "symlink" || parent_is_symlink(&entry.path);
443
444 if !entry.created && !is_symlinked_dir && !force {
455 results.push((
456 entry.path.clone(),
457 entry.skill.clone(),
458 RemovalOutcome::RefusedNotWritten,
459 ));
460 continue;
461 }
462 let modified = matches!(state_of(&entry, &wanted), State::Modified);
463 if modified && !force {
464 results.push((
465 entry.path.clone(),
466 entry.skill.clone(),
467 RemovalOutcome::RefusedModified,
468 ));
469 keep.push(entry);
470 continue;
471 }
472 let removal_target: &Path = if is_symlinked_dir {
473 entry.path.parent().unwrap_or(&entry.path)
474 } else {
475 &entry.path
476 };
477 let existed = removal_target.exists() || std::fs::symlink_metadata(removal_target).is_ok();
478 remove_existing(removal_target)?;
479 if !is_symlinked_dir {
484 if let Some(dir) = entry.path.parent() {
485 let is_empty = std::fs::read_dir(dir)
486 .map(|mut i| i.next().is_none())
487 .unwrap_or(false);
488 if is_empty {
489 let _ = std::fs::remove_dir(dir);
490 }
491 }
492 }
493 let outcome = if existed {
494 RemovalOutcome::Removed
495 } else {
496 RemovalOutcome::Absent
497 };
498 results.push((entry.path.clone(), entry.skill.clone(), outcome));
499 }
500
501 save_state(&keep)?;
502 Ok(results)
503}
504
505pub fn detect_agents(root: &Path) -> Vec<Agent> {
508 let mut found = Vec::new();
509 let shares_agents = [".agents", ".cursor", ".opencode"]
510 .iter()
511 .any(|d| root.join(d).is_dir());
512 if shares_agents {
513 found.push(Agent::Agents);
514 }
515 if root.join(".claude").is_dir() {
516 found.push(Agent::Claude);
517 }
518 found
519}
520
521fn write_file(path: &Path, contents: &str) -> Result<()> {
522 if let Some(parent) = path.parent() {
523 std::fs::create_dir_all(parent).map_err(BbError::Io)?;
524 }
525 std::fs::write(path, contents).map_err(BbError::Io)?;
526 Ok(())
527}
528
529fn remove_existing(path: &Path) -> Result<()> {
534 match std::fs::symlink_metadata(path) {
535 Ok(meta) => {
536 if meta.file_type().is_symlink() || !meta.is_dir() {
537 std::fs::remove_file(path).map_err(BbError::Io)?;
538 } else {
539 std::fs::remove_dir_all(path).map_err(BbError::Io)?;
540 }
541 Ok(())
542 }
543 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
544 Err(err) => Err(BbError::Io(err)),
545 }
546}
547
548fn install_claude_dir(root: &Path, agents_installed: bool, skill: &Skill) -> Result<&'static str> {
552 let claude_dir = root.join(".claude").join("skills").join(skill.name);
553 let claude_file = claude_dir.join("SKILL.md");
554
555 if agents_installed {
556 #[cfg(unix)]
557 {
558 if let Some(parent) = claude_dir.parent() {
559 std::fs::create_dir_all(parent).map_err(BbError::Io)?;
560 }
561 remove_existing(&claude_dir)?;
562 let target = Path::new("..")
563 .join("..")
564 .join(".agents")
565 .join("skills")
566 .join(skill.name);
567 if std::os::unix::fs::symlink(&target, &claude_dir).is_ok() {
568 return Ok("symlink");
569 }
570 }
571 }
572
573 remove_existing(&claude_dir)?;
574 write_file(&claude_file, skill.content)?;
575 Ok("file")
576}
577
578pub fn install(
579 root: &Path,
580 agents: &[Agent],
581 skills: &[&'static Skill],
582 force: bool,
583) -> Result<Vec<Outcome>> {
584 let (mut state, _warning) = load_state();
585 let mut outcomes = Vec::new();
586
587 for skill in skills {
588 let wanted = content_hash(skill.content.as_bytes());
589
590 let agents_dir_present = root
591 .join(".agents")
592 .join("skills")
593 .join(skill.name)
594 .join("SKILL.md")
595 .exists()
596 || agents.contains(&Agent::Agents);
597
598 for agent in agents {
599 let path = skill_file(root, *agent, skill);
600 let recorded = state.iter().find(|e| e.path == path).cloned();
601 let on_disk = std::fs::read(&path).ok();
602
603 let action = match (&on_disk, &recorded) {
604 (None, _) => Action::Installed,
605 (Some(bytes), _) if content_hash(bytes) == wanted => Action::Unchanged,
606 (Some(bytes), Some(entry)) if content_hash(bytes) == entry.sha256 => {
608 Action::Refreshed
609 }
610 (Some(_), _) if force => Action::Refreshed,
612 (Some(_), _) => Action::SkippedModified,
613 };
614
615 let created = match action {
620 Action::Unchanged => recorded.as_ref().map(|e| e.created).unwrap_or(false),
621 _ => true,
622 };
623
624 let mut kind = "file".to_string();
625 if action != Action::SkippedModified && action != Action::Unchanged {
626 if *agent == Agent::Claude {
627 kind = install_claude_dir(root, agents_dir_present, skill)?.to_string();
628 } else {
629 write_file(&path, skill.content)?;
630 }
631 } else if let Some(entry) = &recorded {
632 kind = entry.kind.clone();
633 } else if parent_is_symlink(&path) {
634 kind = "symlink".to_string();
640 }
641
642 if action != Action::SkippedModified {
643 state.retain(|e| e.path != path);
644 state.push(Entry {
645 path: path.clone(),
646 agent: agent.as_str().to_string(),
647 kind,
648 sha256: wanted.clone(),
649 version: env!("CARGO_PKG_VERSION").to_string(),
650 skill: skill.name.to_string(),
651 created,
652 });
653 }
654
655 outcomes.push(Outcome {
656 path,
657 agent: agent.as_str().to_string(),
658 skill: skill.name.to_string(),
659 action,
660 });
661 }
662 }
663
664 save_state(&state)?;
665 Ok(outcomes)
666}
667
668fn claude_root_from_entry_path(path: &Path) -> Result<&Path> {
673 path.parent()
674 .and_then(Path::parent)
675 .and_then(Path::parent)
676 .and_then(Path::parent)
677 .ok_or_else(|| {
678 BbError::Config(format!(
679 "cannot determine the project root from {}",
680 path.display()
681 ))
682 })
683}
684
685fn restore_claude_link(entry_path: &Path, skill: &Skill) -> Result<String> {
692 if !is_shaped_like_a_skill_path(entry_path) {
693 return Err(BbError::Config(format!(
694 "refusing to touch {} — does not look like a skill path bb would have written",
695 entry_path.display()
696 )));
697 }
698 let root = claude_root_from_entry_path(entry_path)?;
699 let agents_installed = root
700 .join(".agents")
701 .join("skills")
702 .join(skill.name)
703 .join("SKILL.md")
704 .exists();
705 Ok(install_claude_dir(root, agents_installed, skill)?.to_string())
706}
707
708pub fn refresh_tracked(missing: MissingPolicy) -> Result<Vec<Outcome>> {
730 let (state, warning) = load_state();
731 if let Some(warning) = warning {
732 crate::output::warn(&warning);
733 }
734 let mut outcomes = Vec::new();
735 let mut kept: Vec<Entry> = Vec::new();
736
737 for mut entry in state {
738 if !is_shaped_like_a_skill_path(&entry.path) {
739 crate::output::warn(&format!(
740 "refusing to touch {} — does not look like a skill path bb would have written",
741 entry.path.display()
742 ));
743 entry.version = env!("CARGO_PKG_VERSION").to_string();
748 kept.push(entry);
749 continue;
750 }
751 let Some(skill) = skill_by_name(&entry.skill) else {
756 entry.version = env!("CARGO_PKG_VERSION").to_string();
758 kept.push(entry);
759 continue;
760 };
761
762 if entry
773 .path
774 .parent()
775 .and_then(Path::parent)
776 .is_some_and(ancestor_is_definitely_gone)
777 {
778 outcomes.push(Outcome {
779 path: entry.path.clone(),
780 agent: entry.agent.clone(),
781 skill: entry.skill.clone(),
782 action: Action::Pruned,
783 });
784 continue;
785 }
786
787 let wanted = content_hash(skill.content.as_bytes());
788 let disk_state = state_of(&entry, &wanted);
789
790 if disk_state == State::Missing && missing == MissingPolicy::Preserve {
794 kept.push(entry);
795 continue;
796 }
797
798 let action = match disk_state {
799 State::Stale => match write_file(&entry.path, skill.content) {
803 Ok(()) => {
804 entry.sha256 = wanted.clone();
805 Action::Refreshed
806 }
807 Err(_) => Action::Failed,
808 },
809 State::Missing => {
814 let restored: Result<String> = if entry.kind == "symlink" {
815 restore_claude_link(&entry.path, skill)
816 } else {
817 write_file(&entry.path, skill.content).map(|()| "file".to_string())
818 };
819 match restored {
820 Ok(kind) => {
821 entry.kind = kind;
822 entry.sha256 = wanted.clone();
823 Action::Refreshed
824 }
825 Err(_) => Action::Failed,
826 }
827 }
828 State::Modified => Action::SkippedModified,
829 State::Current => Action::Unchanged,
830 };
831
832 if action != Action::Failed {
837 entry.version = env!("CARGO_PKG_VERSION").to_string();
838 }
839
840 outcomes.push(Outcome {
841 path: entry.path.clone(),
842 agent: entry.agent.clone(),
843 skill: entry.skill.clone(),
844 action,
845 });
846 kept.push(entry);
847 }
848
849 save_state(&kept)?;
850 Ok(outcomes)
851}
852
853#[cfg(test)]
854#[allow(clippy::unwrap_used, clippy::expect_used)]
855mod tests {
856 use super::*;
857
858 fn bb_skill() -> &'static Skill {
859 skill_by_name("bitbucket-cloud").unwrap()
860 }
861
862 #[test]
865 fn embedded_skill_is_present_and_has_frontmatter() {
866 assert!(!bb_skill().content.trim().is_empty());
867 assert!(
868 bb_skill().content.starts_with("---"),
869 "skill must open with yaml frontmatter"
870 );
871 assert!(
872 bb_skill().content.contains("name: bitbucket-cloud"),
873 "frontmatter should name the skill"
874 );
875 }
876
877 #[test]
878 fn content_hash_is_stable_and_distinguishes_content() {
879 assert_eq!(content_hash(b"abc"), content_hash(b"abc"));
880 assert_ne!(content_hash(b"abc"), content_hash(b"abd"));
881 assert_eq!(content_hash(b"abc").len(), 64);
883 }
884
885 #[test]
886 #[serial_test::serial]
887 fn state_path_prefers_xdg_config_home() {
888 temp_env(
889 &[
890 ("XDG_CONFIG_HOME", Some("/tmp/xdg")),
891 ("HOME", Some("/tmp/home")),
892 ],
893 || {
894 assert_eq!(
895 state_path(),
896 std::path::Path::new("/tmp/xdg/bb/skills.json")
897 );
898 },
899 );
900 }
901
902 #[test]
903 #[serial_test::serial]
904 fn state_path_falls_back_to_home_config() {
905 temp_env(
906 &[("XDG_CONFIG_HOME", None), ("HOME", Some("/tmp/home"))],
907 || {
908 assert_eq!(
909 state_path(),
910 std::path::Path::new("/tmp/home/.config/bb/skills.json")
911 );
912 },
913 );
914 }
915
916 #[test]
917 #[serial_test::serial]
918 fn saved_state_round_trips() {
919 let dir = tempfile::tempdir().unwrap();
920 temp_env(
921 &[
922 ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
923 ("HOME", None),
924 ],
925 || {
926 let entries = vec![Entry {
927 path: std::path::PathBuf::from("/p/.agents/skills/bitbucket-cloud/SKILL.md"),
928 agent: "agents".into(),
929 kind: "file".into(),
930 sha256: content_hash(bb_skill().content.as_bytes()),
931 version: env!("CARGO_PKG_VERSION").into(),
932 skill: "bitbucket-cloud".into(),
933 created: true,
934 }];
935 save_state(&entries).unwrap();
936 let (loaded, warning) = load_state();
937 assert!(warning.is_none());
938 assert_eq!(loaded.len(), 1);
939 assert_eq!(loaded[0].agent, "agents");
940 assert_eq!(loaded[0].sha256, entries[0].sha256);
941 },
942 );
943 }
944
945 #[test]
947 #[serial_test::serial]
948 fn corrupt_state_is_tolerated_with_a_warning() {
949 let dir = tempfile::tempdir().unwrap();
950 temp_env(
951 &[
952 ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
953 ("HOME", None),
954 ],
955 || {
956 let p = state_path();
957 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
958 std::fs::write(&p, "{not json").unwrap();
959 let (loaded, warning) = load_state();
960 assert!(loaded.is_empty());
961 assert!(warning.is_some(), "corrupt state should warn");
962 },
963 );
964 }
965
966 #[test]
971 #[serial_test::serial]
972 fn status_reports_stale_when_the_binary_shipped_newer_text() {
973 let dir = tempfile::tempdir().unwrap();
974 let root = tempfile::tempdir().unwrap();
975 temp_env(
976 &[
977 ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
978 ("HOME", None),
979 ],
980 || {
981 let path = skill_file(root.path(), Agent::Agents, bb_skill());
982 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
983 let old = "---\nname: bitbucket-cloud\n---\nold text\n";
984 std::fs::write(&path, old).unwrap();
985 save_state(&[Entry {
986 path: path.clone(),
987 agent: "agents".into(),
988 kind: "file".into(),
989 sha256: content_hash(old.as_bytes()),
990 version: "0.0.1".into(),
991 skill: "bitbucket-cloud".into(),
992 created: true,
993 }])
994 .unwrap();
995
996 let (rows, warning) = status();
997 assert!(warning.is_none());
998 assert_eq!(rows.len(), 1);
999 assert_eq!(
1000 rows[0].state,
1001 State::Stale,
1002 "on-disk text matches the recorded sha256, just not the binary's current text"
1003 );
1004
1005 std::fs::write(&path, bb_skill().content).unwrap();
1008 save_state(&[Entry {
1009 path: path.clone(),
1010 agent: "agents".into(),
1011 kind: "file".into(),
1012 sha256: content_hash(bb_skill().content.as_bytes()),
1013 version: env!("CARGO_PKG_VERSION").into(),
1014 skill: "bitbucket-cloud".into(),
1015 created: true,
1016 }])
1017 .unwrap();
1018 let (rows, _) = status();
1019 assert_eq!(rows[0].state, State::Current);
1020 },
1021 );
1022 }
1023
1024 #[test]
1031 #[serial_test::serial]
1032 fn uninstall_removes_the_claude_link_without_deleting_its_target() {
1033 let dir = tempfile::tempdir().unwrap();
1034 let cfg = tempfile::tempdir().unwrap();
1035 temp_env(
1036 &[
1037 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1038 ("HOME", None),
1039 ],
1040 || {
1041 install(
1042 dir.path(),
1043 &[Agent::Agents, Agent::Claude],
1044 &[bb_skill()],
1045 false,
1046 )
1047 .unwrap();
1048 let agents_path = skill_file(dir.path(), Agent::Agents, bb_skill());
1049 let claude_root = dir.path().join(".claude");
1050 assert!(agents_path.is_file(), "sanity: agents copy installed");
1051
1052 let results = uninstall(Some(&claude_root), &[bb_skill()], false).unwrap();
1053 assert_eq!(results.len(), 1, "only the claude entry was in scope");
1054 assert_eq!(
1055 results[0].2,
1056 RemovalOutcome::Removed,
1057 "the claude entry should report removed"
1058 );
1059
1060 let claude_dir = dir.path().join(".claude/skills/bitbucket-cloud");
1061 assert!(
1062 !claude_dir.exists(),
1063 "the claude link (or fallback file) should be gone"
1064 );
1065 assert!(
1066 agents_path.is_file(),
1067 "removing the claude link must not delete the agents copy it points at"
1068 );
1069
1070 let (remaining, _) = load_state();
1071 assert_eq!(remaining.len(), 1, "the agents entry stays tracked");
1072 assert_eq!(remaining[0].agent, "agents");
1073 },
1074 );
1075 }
1076
1077 #[test]
1078 #[serial_test::serial]
1079 fn missing_state_is_empty_and_silent() {
1080 let dir = tempfile::tempdir().unwrap();
1081 temp_env(
1082 &[
1083 ("XDG_CONFIG_HOME", Some(dir.path().to_str().unwrap())),
1084 ("HOME", None),
1085 ],
1086 || {
1087 let (loaded, warning) = load_state();
1088 assert!(loaded.is_empty());
1089 assert!(warning.is_none());
1090 },
1091 );
1092 }
1093
1094 struct EnvGuard {
1098 saved: Vec<(String, Option<String>)>,
1099 }
1100
1101 impl Drop for EnvGuard {
1102 fn drop(&mut self) {
1103 for (k, v) in &self.saved {
1104 match v {
1105 Some(val) => std::env::set_var(k, val),
1106 None => std::env::remove_var(k),
1107 }
1108 }
1109 }
1110 }
1111
1112 fn temp_env(vars: &[(&str, Option<&str>)], f: impl FnOnce()) {
1116 let saved: Vec<(String, Option<String>)> = vars
1117 .iter()
1118 .map(|(k, _)| ((*k).to_string(), std::env::var(k).ok()))
1119 .collect();
1120 let _guard = EnvGuard { saved };
1121 for (k, v) in vars {
1122 match v {
1123 Some(val) => std::env::set_var(k, val),
1124 None => std::env::remove_var(k),
1125 }
1126 }
1127 f();
1128 }
1129
1130 #[test]
1131 #[serial_test::serial]
1132 fn temp_env_restores_vars_even_if_the_closure_panics() {
1133 std::env::set_var("XDG_CONFIG_HOME", "/before/panic");
1134 std::env::remove_var("HOME");
1135
1136 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1137 temp_env(
1138 &[
1139 ("XDG_CONFIG_HOME", Some("/tmp/during-panic")),
1140 ("HOME", Some("/tmp/home")),
1141 ],
1142 || panic!("simulated test failure inside temp_env"),
1143 );
1144 }));
1145 assert!(result.is_err(), "closure should have panicked");
1146
1147 assert_eq!(
1148 std::env::var("XDG_CONFIG_HOME").ok(),
1149 Some("/before/panic".to_string()),
1150 "XDG_CONFIG_HOME must be restored even after a panic"
1151 );
1152 assert_eq!(
1153 std::env::var("HOME").ok(),
1154 None,
1155 "HOME must be restored to unset even after a panic"
1156 );
1157
1158 std::env::remove_var("XDG_CONFIG_HOME");
1159 }
1160
1161 #[test]
1162 #[serial_test::serial]
1163 fn install_writes_the_embedded_content() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let cfg = tempfile::tempdir().unwrap();
1166 temp_env(
1167 &[
1168 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1169 ("HOME", None),
1170 ],
1171 || {
1172 let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1173 assert_eq!(outcomes.len(), 1);
1174 assert!(matches!(outcomes[0].action, Action::Installed));
1175
1176 let written =
1177 std::fs::read_to_string(skill_file(dir.path(), Agent::Agents, bb_skill()))
1178 .unwrap();
1179 assert_eq!(
1180 written,
1181 bb_skill().content,
1182 "installed content must equal the embedded skill"
1183 );
1184
1185 let (state, _) = load_state();
1186 assert_eq!(state.len(), 1);
1187 assert_eq!(state[0].sha256, content_hash(bb_skill().content.as_bytes()));
1188 },
1189 );
1190 }
1191
1192 #[test]
1193 #[serial_test::serial]
1194 fn a_second_install_reports_unchanged_and_rewrites_nothing() {
1195 let dir = tempfile::tempdir().unwrap();
1196 let cfg = tempfile::tempdir().unwrap();
1197 temp_env(
1198 &[
1199 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1200 ("HOME", None),
1201 ],
1202 || {
1203 install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1204 let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1205 assert!(
1206 matches!(outcomes[0].action, Action::Unchanged),
1207 "{:?}",
1208 outcomes[0].action
1209 );
1210 },
1211 );
1212 }
1213
1214 #[test]
1216 #[serial_test::serial]
1217 fn a_modified_file_is_refused_and_left_byte_identical() {
1218 let dir = tempfile::tempdir().unwrap();
1219 let cfg = tempfile::tempdir().unwrap();
1220 temp_env(
1221 &[
1222 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1223 ("HOME", None),
1224 ],
1225 || {
1226 install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1227 let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1228 std::fs::write(&path, "# my own notes\n").unwrap();
1229
1230 let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1231 assert!(matches!(outcomes[0].action, Action::SkippedModified));
1232 assert_eq!(std::fs::read_to_string(&path).unwrap(), "# my own notes\n");
1233 },
1234 );
1235 }
1236
1237 #[test]
1238 #[serial_test::serial]
1239 fn force_overwrites_a_modified_file_and_updates_the_hash() {
1240 let dir = tempfile::tempdir().unwrap();
1241 let cfg = tempfile::tempdir().unwrap();
1242 temp_env(
1243 &[
1244 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1245 ("HOME", None),
1246 ],
1247 || {
1248 install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1249 let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1250 std::fs::write(&path, "# my own notes\n").unwrap();
1251
1252 let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], true).unwrap();
1253 assert!(matches!(
1254 outcomes[0].action,
1255 Action::Refreshed | Action::Installed
1256 ));
1257 assert_eq!(std::fs::read_to_string(&path).unwrap(), bb_skill().content);
1258 let (state, _) = load_state();
1259 assert_eq!(state[0].sha256, content_hash(bb_skill().content.as_bytes()));
1260 },
1261 );
1262 }
1263
1264 #[test]
1267 #[serial_test::serial]
1268 fn a_stale_file_is_refreshed_silently() {
1269 let dir = tempfile::tempdir().unwrap();
1270 let cfg = tempfile::tempdir().unwrap();
1271 temp_env(
1272 &[
1273 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1274 ("HOME", None),
1275 ],
1276 || {
1277 let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1278 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1279 let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1280 std::fs::write(&path, old).unwrap();
1281 save_state(&[Entry {
1282 path: path.clone(),
1283 agent: "agents".into(),
1284 kind: "file".into(),
1285 sha256: content_hash(old.as_bytes()),
1286 version: "0.0.1".into(),
1287 skill: "bitbucket-cloud".into(),
1288 created: true,
1289 }])
1290 .unwrap();
1291
1292 let outcomes = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1293 assert!(
1294 matches!(outcomes[0].action, Action::Refreshed),
1295 "{:?}",
1296 outcomes[0].action
1297 );
1298 assert_eq!(std::fs::read_to_string(&path).unwrap(), bb_skill().content);
1299 },
1300 );
1301 }
1302
1303 #[test]
1304 #[serial_test::serial]
1305 fn claude_install_links_to_the_agents_copy() {
1306 let dir = tempfile::tempdir().unwrap();
1307 let cfg = tempfile::tempdir().unwrap();
1308 temp_env(
1309 &[
1310 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1311 ("HOME", None),
1312 ],
1313 || {
1314 install(
1315 dir.path(),
1316 &[Agent::Agents, Agent::Claude],
1317 &[bb_skill()],
1318 false,
1319 )
1320 .unwrap();
1321 let claude = dir.path().join(".claude/skills").join(bb_skill().name);
1322 let content = std::fs::read_to_string(claude.join("SKILL.md"))
1325 .or_else(|_| std::fs::read_to_string(&claude))
1326 .unwrap();
1327 assert_eq!(content, bb_skill().content);
1328 },
1329 );
1330 }
1331
1332 #[test]
1333 fn detect_finds_each_agent_directory() {
1334 let dir = tempfile::tempdir().unwrap();
1335 assert!(
1336 detect_agents(dir.path()).is_empty(),
1337 "nothing present means nothing detected"
1338 );
1339
1340 std::fs::create_dir_all(dir.path().join(".cursor")).unwrap();
1341 assert_eq!(
1342 detect_agents(dir.path()),
1343 vec![Agent::Agents],
1344 "cursor reads .agents"
1345 );
1346
1347 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
1348 let found = detect_agents(dir.path());
1349 assert!(found.contains(&Agent::Agents) && found.contains(&Agent::Claude));
1350 }
1351
1352 #[test]
1357 #[serial_test::serial]
1358 fn refresh_recreates_a_deleted_symlink_as_a_file_when_no_agents_copy_exists() {
1359 let dir = tempfile::tempdir().unwrap();
1360 let cfg = tempfile::tempdir().unwrap();
1361 temp_env(
1362 &[
1363 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1364 ("HOME", None),
1365 ],
1366 || {
1367 install(
1368 dir.path(),
1369 &[Agent::Agents, Agent::Claude],
1370 &[bb_skill()],
1371 false,
1372 )
1373 .unwrap();
1374 let claude_path = skill_file(dir.path(), Agent::Claude, bb_skill());
1375 let claude_dir = claude_path.parent().unwrap();
1376
1377 let (state, _) = load_state();
1381 let claude_entry = state.iter().find(|e| e.agent == "claude").cloned().unwrap();
1382 assert_eq!(claude_entry.kind, "symlink", "sanity: install made a link");
1383 save_state(&[claude_entry]).unwrap();
1384
1385 remove_existing(claude_dir).unwrap();
1386 std::fs::remove_dir_all(dir.path().join(".agents")).unwrap();
1387
1388 let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
1389 assert_eq!(outcomes.len(), 1);
1390 assert!(matches!(outcomes[0].action, Action::Refreshed));
1391
1392 assert_eq!(
1393 std::fs::read_to_string(&claude_path).unwrap(),
1394 bb_skill().content
1395 );
1396 let (state, _) = load_state();
1397 assert_eq!(
1398 state[0].kind, "file",
1399 "disk fell back to a real file, so state must say so too"
1400 );
1401 },
1402 );
1403 }
1404
1405 #[test]
1408 #[serial_test::serial]
1409 fn refresh_recreates_a_deleted_symlink_as_a_symlink_when_the_agents_copy_survives() {
1410 let dir = tempfile::tempdir().unwrap();
1411 let cfg = tempfile::tempdir().unwrap();
1412 temp_env(
1413 &[
1414 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1415 ("HOME", None),
1416 ],
1417 || {
1418 install(
1419 dir.path(),
1420 &[Agent::Agents, Agent::Claude],
1421 &[bb_skill()],
1422 false,
1423 )
1424 .unwrap();
1425 let claude_dir = skill_file(dir.path(), Agent::Claude, bb_skill())
1426 .parent()
1427 .unwrap()
1428 .to_path_buf();
1429
1430 remove_existing(&claude_dir).unwrap();
1431 assert!(!claude_dir.exists(), "sanity: the link is gone");
1432
1433 let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
1434 let claude_outcome = outcomes.iter().find(|o| o.agent == "claude").unwrap();
1435 assert!(matches!(claude_outcome.action, Action::Refreshed));
1436
1437 assert!(
1438 std::fs::symlink_metadata(&claude_dir)
1439 .unwrap()
1440 .file_type()
1441 .is_symlink(),
1442 "the agents copy was still there, so a link should come back, not a file"
1443 );
1444 assert_eq!(
1445 std::fs::read_to_string(claude_dir.join("SKILL.md")).unwrap(),
1446 bb_skill().content
1447 );
1448 let (state, _) = load_state();
1449 let claude_entry = state.iter().find(|e| e.agent == "claude").unwrap();
1450 assert_eq!(claude_entry.kind, "symlink");
1451 },
1452 );
1453 }
1454
1455 #[test]
1459 #[serial_test::serial]
1460 fn refresh_updates_content_through_an_intact_symlink_without_replacing_it() {
1461 let dir = tempfile::tempdir().unwrap();
1462 let cfg = tempfile::tempdir().unwrap();
1463 temp_env(
1464 &[
1465 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1466 ("HOME", None),
1467 ],
1468 || {
1469 install(
1470 dir.path(),
1471 &[Agent::Agents, Agent::Claude],
1472 &[bb_skill()],
1473 false,
1474 )
1475 .unwrap();
1476 let claude_path = skill_file(dir.path(), Agent::Claude, bb_skill());
1477 let claude_dir = claude_path.parent().unwrap().to_path_buf();
1478
1479 let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1480 std::fs::write(&claude_path, old).unwrap();
1481
1482 let (state, _) = load_state();
1486 let mut claude_entry = state.iter().find(|e| e.agent == "claude").cloned().unwrap();
1487 claude_entry.sha256 = content_hash(old.as_bytes());
1488 save_state(&[claude_entry]).unwrap();
1489
1490 let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
1491 assert_eq!(outcomes.len(), 1);
1492 assert!(matches!(outcomes[0].action, Action::Refreshed));
1493
1494 assert!(
1495 std::fs::symlink_metadata(&claude_dir)
1496 .unwrap()
1497 .file_type()
1498 .is_symlink(),
1499 "an intact link must not be replaced by a file just to refresh content"
1500 );
1501 assert_eq!(
1502 std::fs::read_to_string(&claude_path).unwrap(),
1503 bb_skill().content
1504 );
1505 let (state, _) = load_state();
1506 assert_eq!(state[0].kind, "symlink");
1507 },
1508 );
1509 }
1510
1511 #[test]
1512 fn shape_guard_accepts_only_the_two_real_skill_locations() {
1513 assert!(is_shaped_like_a_skill_path(Path::new(
1514 "/proj/.agents/skills/bitbucket-cloud/SKILL.md"
1515 )));
1516 assert!(is_shaped_like_a_skill_path(Path::new(
1517 "/proj/.claude/skills/bitbucket-cloud/SKILL.md"
1518 )));
1519 for bad in [
1520 "/proj/src",
1521 "/proj/src/main.rs",
1522 "/proj/.agents/skills/bitbucket-cloud",
1523 "/proj/.agents/skills/some-other-skill/SKILL.md",
1524 "/proj/.opencode/skills/bitbucket-cloud/SKILL.md",
1525 "/etc/passwd",
1526 ] {
1527 assert!(
1528 !is_shaped_like_a_skill_path(Path::new(bad)),
1529 "{bad} should have been refused"
1530 );
1531 }
1532 }
1533
1534 #[test]
1539 #[serial_test::serial]
1540 fn uninstall_does_not_follow_a_hand_made_symlink_into_its_target() {
1541 let dir = tempfile::tempdir().unwrap();
1542 let cfg = tempfile::tempdir().unwrap();
1543 temp_env(
1544 &[
1545 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1546 ("HOME", None),
1547 ],
1548 || {
1549 let agents_path = skill_file(dir.path(), Agent::Agents, bb_skill());
1553 std::fs::create_dir_all(agents_path.parent().unwrap()).unwrap();
1554 std::fs::write(&agents_path, bb_skill().content).unwrap();
1555
1556 let claude_dir = dir
1557 .path()
1558 .join(".claude")
1559 .join("skills")
1560 .join(bb_skill().name);
1561 std::fs::create_dir_all(claude_dir.parent().unwrap()).unwrap();
1562 #[cfg(unix)]
1563 std::os::unix::fs::symlink(
1564 Path::new("..")
1565 .join("..")
1566 .join(".agents")
1567 .join("skills")
1568 .join(bb_skill().name),
1569 &claude_dir,
1570 )
1571 .unwrap();
1572
1573 let outcomes = install(dir.path(), &[Agent::Claude], &[bb_skill()], false).unwrap();
1574 assert!(
1575 matches!(outcomes[0].action, Action::Unchanged),
1576 "sanity: content already matches, so install should not rewrite it"
1577 );
1578
1579 let results = uninstall(None, &[bb_skill()], false).unwrap();
1580 assert_eq!(results.len(), 1);
1581 assert_eq!(results[0].2, RemovalOutcome::Removed);
1582
1583 assert!(
1584 std::fs::read_to_string(&agents_path).unwrap() == bb_skill().content,
1585 "the .agents copy must survive uninstall of the claude link"
1586 );
1587 assert!(
1588 std::fs::symlink_metadata(&claude_dir).is_err(),
1589 "no dangling claude symlink should remain (Path::exists() would \
1590 wrongly report false for a dangling link, so this checks \
1591 symlink_metadata instead)"
1592 );
1593 },
1594 );
1595 }
1596
1597 #[test]
1600 #[serial_test::serial]
1601 fn uninstall_refuses_a_state_entry_pointing_outside_the_skill_shape() {
1602 let dir = tempfile::tempdir().unwrap();
1603 let cfg = tempfile::tempdir().unwrap();
1604 temp_env(
1605 &[
1606 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1607 ("HOME", None),
1608 ],
1609 || {
1610 let victim_dir = dir.path().join("src");
1611 std::fs::create_dir_all(&victim_dir).unwrap();
1612 std::fs::write(victim_dir.join("main.rs"), "fn main() {}").unwrap();
1613
1614 let victim_file = dir.path().join("Cargo.toml");
1615 std::fs::write(&victim_file, "[package]").unwrap();
1616
1617 save_state(&[
1618 Entry {
1619 path: victim_dir.clone(),
1620 agent: "agents".into(),
1621 kind: "file".into(),
1622 sha256: "deadbeef".into(),
1623 version: "0.0.1".into(),
1624 skill: "bitbucket-cloud".into(),
1625 created: true,
1626 },
1627 Entry {
1628 path: victim_file.clone(),
1629 agent: "agents".into(),
1630 kind: "file".into(),
1631 sha256: "deadbeef".into(),
1632 version: "0.0.1".into(),
1633 skill: "bitbucket-cloud".into(),
1634 created: true,
1635 },
1636 ])
1637 .unwrap();
1638
1639 let results = uninstall(None, &[bb_skill()], true).unwrap();
1640 assert_eq!(results.len(), 2);
1641 assert!(results
1642 .iter()
1643 .all(|(_, _, o)| *o == RemovalOutcome::RefusedUnsafePath));
1644
1645 assert!(victim_dir.is_dir(), "unrelated directory must survive");
1646 assert!(
1647 victim_dir.join("main.rs").exists(),
1648 "unrelated directory's contents must survive"
1649 );
1650 assert!(victim_file.is_file(), "unrelated file must survive");
1651
1652 let (remaining, _) = load_state();
1654 assert_eq!(remaining.len(), 2);
1655 },
1656 );
1657 }
1658
1659 #[test]
1661 #[serial_test::serial]
1662 fn uninstall_still_removes_a_legitimate_entry() {
1663 let dir = tempfile::tempdir().unwrap();
1664 let cfg = tempfile::tempdir().unwrap();
1665 temp_env(
1666 &[
1667 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1668 ("HOME", None),
1669 ],
1670 || {
1671 install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1672 let results = uninstall(None, &[bb_skill()], false).unwrap();
1673 assert_eq!(
1674 results,
1675 vec![(
1676 skill_file(dir.path(), Agent::Agents, bb_skill()),
1677 bb_skill().name.to_string(),
1678 RemovalOutcome::Removed
1679 )]
1680 );
1681 },
1682 );
1683 }
1684
1685 #[test]
1699 #[serial_test::serial]
1700 fn uninstall_leaves_a_file_bb_never_wrote() {
1701 let dir = tempfile::tempdir().unwrap();
1702 let cfg = tempfile::tempdir().unwrap();
1703 temp_env(
1704 &[
1705 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1706 ("HOME", None),
1707 ],
1708 || {
1709 let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1712 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1713 std::fs::write(&path, bb_skill().content).unwrap();
1714
1715 let installed =
1716 install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1717 assert_eq!(
1718 installed[0].action,
1719 Action::Unchanged,
1720 "bb wrote nothing, so the action is still Unchanged"
1721 );
1722
1723 let results = uninstall(None, &[bb_skill()], false).unwrap();
1724 assert_eq!(
1725 results,
1726 vec![(
1727 path.clone(),
1728 bb_skill().name.to_string(),
1729 RemovalOutcome::RefusedNotWritten
1730 )]
1731 );
1732 assert!(
1733 path.exists(),
1734 "uninstall deleted a file bb never wrote: {}",
1735 path.display()
1736 );
1737
1738 let (entries, _) = load_state();
1741 assert!(
1742 !entries.iter().any(|e| e.path == path),
1743 "the adopted entry should be untracked after uninstall"
1744 );
1745 },
1746 );
1747 }
1748
1749 #[test]
1753 #[serial_test::serial]
1754 fn force_removes_a_file_bb_never_wrote() {
1755 let dir = tempfile::tempdir().unwrap();
1756 let cfg = tempfile::tempdir().unwrap();
1757 temp_env(
1758 &[
1759 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1760 ("HOME", None),
1761 ],
1762 || {
1763 let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1764 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1765 std::fs::write(&path, bb_skill().content).unwrap();
1766 install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1767
1768 let results = uninstall(None, &[bb_skill()], true).unwrap();
1769 assert_eq!(results[0].2, RemovalOutcome::Removed);
1770 assert!(!path.exists());
1771 },
1772 );
1773 }
1774
1775 #[test]
1779 #[serial_test::serial]
1780 fn a_reinstalled_file_is_still_removable() {
1781 let dir = tempfile::tempdir().unwrap();
1782 let cfg = tempfile::tempdir().unwrap();
1783 temp_env(
1784 &[
1785 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1786 ("HOME", None),
1787 ],
1788 || {
1789 install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1790 let again = install(dir.path(), &[Agent::Agents], &[bb_skill()], false).unwrap();
1791 assert_eq!(again[0].action, Action::Unchanged);
1792
1793 let results = uninstall(None, &[bb_skill()], false).unwrap();
1794 assert_eq!(results[0].2, RemovalOutcome::Removed);
1795 },
1796 );
1797 }
1798
1799 #[test]
1803 #[serial_test::serial]
1804 fn a_legacy_state_entry_without_the_field_is_still_removable() {
1805 let dir = tempfile::tempdir().unwrap();
1806 let cfg = tempfile::tempdir().unwrap();
1807 temp_env(
1808 &[
1809 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1810 ("HOME", None),
1811 ],
1812 || {
1813 let path = skill_file(dir.path(), Agent::Agents, bb_skill());
1814 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1815 std::fs::write(&path, bb_skill().content).unwrap();
1816
1817 let legacy = serde_json::json!([{
1818 "path": path,
1819 "agent": "agents",
1820 "kind": "file",
1821 "sha256": content_hash(bb_skill().content.as_bytes()),
1822 "version": "0.18.0",
1823 "skill": bb_skill().name,
1824 }]);
1825 std::fs::create_dir_all(cfg.path().join("bb")).unwrap();
1826 std::fs::write(
1827 cfg.path().join("bb").join("skills.json"),
1828 serde_json::to_string(&legacy).unwrap(),
1829 )
1830 .unwrap();
1831
1832 let results = uninstall(None, &[bb_skill()], false).unwrap();
1833 assert_eq!(results[0].2, RemovalOutcome::Removed);
1834 },
1835 );
1836 }
1837
1838 #[test]
1842 #[serial_test::serial]
1843 fn refresh_rewrites_a_stale_entry_while_leaving_a_modified_one_alone() {
1844 let stale_root = tempfile::tempdir().unwrap();
1845 let modified_root = tempfile::tempdir().unwrap();
1846 let cfg = tempfile::tempdir().unwrap();
1847 temp_env(
1848 &[
1849 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
1850 ("HOME", None),
1851 ],
1852 || {
1853 let old = "---\nname: bitbucket-cloud\n---\nold text\n";
1854 let stale_path = skill_file(stale_root.path(), Agent::Agents, bb_skill());
1855 std::fs::create_dir_all(stale_path.parent().unwrap()).unwrap();
1856 std::fs::write(&stale_path, old).unwrap();
1857
1858 let modified_path = skill_file(modified_root.path(), Agent::Agents, bb_skill());
1859 std::fs::create_dir_all(modified_path.parent().unwrap()).unwrap();
1860 let ours = "# our own version\n";
1861 std::fs::write(&modified_path, ours).unwrap();
1862
1863 save_state(&[
1864 Entry {
1865 path: stale_path.clone(),
1866 agent: "agents".into(),
1867 kind: "file".into(),
1868 sha256: content_hash(old.as_bytes()),
1869 version: "0.0.1".into(),
1870 skill: "bitbucket-cloud".into(),
1871 created: true,
1872 },
1873 Entry {
1874 path: modified_path.clone(),
1875 agent: "agents".into(),
1876 kind: "file".into(),
1877 sha256: content_hash(old.as_bytes()),
1880 version: "0.0.1".into(),
1881 skill: "bitbucket-cloud".into(),
1882 created: true,
1883 },
1884 ])
1885 .unwrap();
1886
1887 let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
1888 assert_eq!(outcomes.len(), 2);
1889
1890 let stale_outcome = outcomes.iter().find(|o| o.path == stale_path).unwrap();
1891 assert!(matches!(stale_outcome.action, Action::Refreshed));
1892 assert_eq!(
1893 std::fs::read_to_string(&stale_path).unwrap(),
1894 bb_skill().content
1895 );
1896
1897 let modified_outcome = outcomes.iter().find(|o| o.path == modified_path).unwrap();
1898 assert!(matches!(modified_outcome.action, Action::SkippedModified));
1899 assert_eq!(std::fs::read_to_string(&modified_path).unwrap(), ours);
1900 },
1901 );
1902 }
1903
1904 #[test]
1905 fn both_skills_are_registered_and_well_formed() {
1906 let names: Vec<&str> = SKILLS.iter().map(|s| s.name).collect();
1907 assert!(names.contains(&"bitbucket-cloud"));
1908 assert!(names.contains(&"bbc-daily-brief"));
1909 assert!(names.contains(&"bbc-open-pr"));
1910 assert!(names.contains(&"bbc-report-bug"));
1911 for skill in SKILLS.iter() {
1912 assert!(
1913 skill.content.starts_with("---"),
1914 "{} lacks frontmatter",
1915 skill.name
1916 );
1917 assert!(
1918 skill.content.contains(&format!("name: {}", skill.name)),
1919 "{} frontmatter name does not match",
1920 skill.name
1921 );
1922 }
1923 }
1924
1925 #[test]
1934 fn the_bug_report_skill_keeps_its_approval_gate_and_redaction_rules() {
1935 let skill = skill_by_name("bbc-report-bug").expect("bbc-report-bug is registered");
1936
1937 for required in [
1938 "Explicit invocation only",
1940 "Never create the issue without showing it first",
1942 "Redact before you draft",
1944 "never include, redacted or not",
1946 "biokraft/bbcloud",
1948 ] {
1949 assert!(
1950 skill.content.contains(required),
1951 "bbc-report-bug lost the {required:?} rule"
1952 );
1953 }
1954 }
1955
1956 #[test]
1957 fn skill_by_name_resolves_known_and_rejects_unknown() {
1958 assert_eq!(
1959 skill_by_name("bbc-daily-brief").map(|s| s.name),
1960 Some("bbc-daily-brief")
1961 );
1962 assert!(skill_by_name("nope").is_none());
1963 }
1964
1965 #[test]
1966 fn every_skill_path_shape_is_accepted_under_both_layouts() {
1967 for skill in SKILLS.iter() {
1968 for dir in [".agents", ".claude"] {
1969 let path = PathBuf::from(format!("/p/{dir}/skills/{}/SKILL.md", skill.name));
1970 assert!(is_shaped_like_a_skill_path(&path), "rejected {path:?}");
1971 }
1972 }
1973 }
1974
1975 #[test]
1976 fn an_unknown_skill_name_is_still_refused() {
1977 assert!(!is_shaped_like_a_skill_path(&PathBuf::from(
1978 "/p/.agents/skills/other-skill/SKILL.md"
1979 )));
1980 assert!(!is_shaped_like_a_skill_path(&PathBuf::from(
1981 "/p/.agents/bbc-daily-brief/SKILL.md"
1982 )));
1983 assert!(!is_shaped_like_a_skill_path(&PathBuf::from(
1984 "/p/.vscode/skills/bbc-daily-brief/SKILL.md"
1985 )));
1986 }
1987
1988 #[test]
1989 fn an_entry_without_a_skill_field_defaults_to_the_first_skill() {
1990 let entry: Entry = serde_json::from_str(
1991 r#"{"path":"/p/.agents/skills/bitbucket-cloud/SKILL.md","agent":"agents",
1992 "kind":"file","sha256":"abc","version":"0.1.0"}"#,
1993 )
1994 .unwrap();
1995 assert_eq!(entry.skill, "bitbucket-cloud");
1996 }
1997
1998 #[test]
1999 fn state_of_compares_against_each_entrys_own_skill() {
2000 let brief = skill_by_name("bbc-daily-brief").unwrap();
2003 let dir = tempfile::tempdir().unwrap();
2004 let path = dir.path().join("SKILL.md");
2005 std::fs::write(&path, brief.content).unwrap();
2006 let entry = Entry {
2007 path: path.clone(),
2008 agent: "agents".into(),
2009 kind: "file".into(),
2010 sha256: content_hash(brief.content.as_bytes()),
2011 version: "0.1.0".into(),
2012 skill: "bbc-daily-brief".into(),
2013 created: true,
2014 };
2015 assert_eq!(
2016 state_of(&entry, &content_hash(brief.content.as_bytes())),
2017 State::Current
2018 );
2019 }
2020
2021 #[test]
2028 #[serial_test::serial]
2029 fn status_reports_modified_for_an_unknown_skill_name_even_when_disk_matches_the_recorded_hash()
2030 {
2031 let dir = tempfile::tempdir().unwrap();
2032 let cfg = tempfile::tempdir().unwrap();
2033 temp_env(
2034 &[
2035 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2036 ("HOME", None),
2037 ],
2038 || {
2039 let path = dir.path().join(".agents/skills/some-future-skill/SKILL.md");
2040 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2041 let content = "---\nname: some-future-skill\n---\nfrom a newer bb\n";
2042 std::fs::write(&path, content).unwrap();
2043
2044 save_state(&[Entry {
2045 path: path.clone(),
2046 agent: "agents".into(),
2047 kind: "file".into(),
2048 sha256: content_hash(content.as_bytes()),
2051 version: "9.9.9".into(),
2052 skill: "some-future-skill".into(),
2053 created: true,
2054 }])
2055 .unwrap();
2056
2057 let (rows, warning) = status();
2058 assert!(warning.is_none());
2059 assert_eq!(rows.len(), 1);
2060 assert_eq!(
2061 rows[0].state,
2062 State::Modified,
2063 "an unknown skill name must never be reported as Stale"
2064 );
2065 },
2066 );
2067 }
2068
2069 #[test]
2070 fn tracked_version_differs_only_when_an_entry_is_behind() {
2071 let current = env!("CARGO_PKG_VERSION").to_string();
2072 assert!(
2073 !tracked_version_differs(&[]),
2074 "nothing tracked means nothing to do"
2075 );
2076
2077 let up_to_date = Entry {
2078 path: PathBuf::from("/p/.agents/skills/bitbucket-cloud/SKILL.md"),
2079 agent: "agents".into(),
2080 kind: "file".into(),
2081 sha256: "abc".into(),
2082 version: current.clone(),
2083 skill: "bitbucket-cloud".into(),
2084 created: true,
2085 };
2086 assert!(!tracked_version_differs(std::slice::from_ref(&up_to_date)));
2087
2088 let mut behind = up_to_date.clone();
2089 behind.version = "0.0.1".into();
2090 assert!(tracked_version_differs(&[up_to_date, behind]));
2091 }
2092
2093 #[test]
2094 #[serial_test::serial]
2095 fn refresh_prunes_an_entry_whose_directory_tree_is_gone() {
2096 let cfg = tempfile::tempdir().unwrap();
2097 temp_env(
2098 &[
2099 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2100 ("HOME", None),
2101 ],
2102 || {
2103 let gone =
2106 PathBuf::from("/nonexistent-root-xyz/.agents/skills/bitbucket-cloud/SKILL.md");
2107 save_state(&[Entry {
2108 path: gone.clone(),
2109 agent: "agents".into(),
2110 kind: "file".into(),
2111 sha256: "abc".into(),
2112 version: "0.0.1".into(),
2113 skill: "bitbucket-cloud".into(),
2114 created: true,
2115 }])
2116 .unwrap();
2117
2118 let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2119 assert_eq!(outcomes.len(), 1);
2120 assert_eq!(outcomes[0].action, Action::Pruned);
2121 assert!(!gone.exists(), "pruning must not create the file");
2122
2123 let (state, _) = load_state();
2124 assert!(
2125 state.is_empty(),
2126 "the pruned entry must leave the state file"
2127 );
2128 },
2129 );
2130 }
2131
2132 #[test]
2133 #[serial_test::serial]
2134 fn refresh_still_restores_a_missing_file_whose_directory_exists() {
2135 let cfg = tempfile::tempdir().unwrap();
2136 let root = tempfile::tempdir().unwrap();
2137 temp_env(
2138 &[
2139 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2140 ("HOME", None),
2141 ],
2142 || {
2143 let path = skill_file(root.path(), Agent::Agents, bb_skill());
2144 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2145 save_state(&[Entry {
2148 path: path.clone(),
2149 agent: "agents".into(),
2150 kind: "file".into(),
2151 sha256: content_hash(bb_skill().content.as_bytes()),
2152 version: "0.0.1".into(),
2153 skill: "bitbucket-cloud".into(),
2154 created: true,
2155 }])
2156 .unwrap();
2157
2158 let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2159 assert_eq!(outcomes[0].action, Action::Refreshed);
2160 assert_eq!(std::fs::read_to_string(&path).unwrap(), bb_skill().content);
2161 },
2162 );
2163 }
2164
2165 #[test]
2166 #[serial_test::serial]
2167 fn refresh_stamps_the_version_onto_a_skipped_entry_without_touching_the_file() {
2168 let cfg = tempfile::tempdir().unwrap();
2169 let root = tempfile::tempdir().unwrap();
2170 temp_env(
2171 &[
2172 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2173 ("HOME", None),
2174 ],
2175 || {
2176 let path = skill_file(root.path(), Agent::Agents, bb_skill());
2177 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2178 std::fs::write(&path, "locally edited").unwrap();
2179 save_state(&[Entry {
2180 path: path.clone(),
2181 agent: "agents".into(),
2182 kind: "file".into(),
2183 sha256: content_hash(b"something else"),
2184 version: "0.0.1".into(),
2185 skill: "bitbucket-cloud".into(),
2186 created: true,
2187 }])
2188 .unwrap();
2189
2190 let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2191 assert_eq!(outcomes[0].action, Action::SkippedModified);
2192 assert_eq!(
2193 std::fs::read_to_string(&path).unwrap(),
2194 "locally edited",
2195 "a local edit must survive"
2196 );
2197
2198 let (state, _) = load_state();
2201 assert_eq!(state[0].version, env!("CARGO_PKG_VERSION"));
2202 assert!(!tracked_version_differs(&state));
2203 },
2204 );
2205 }
2206
2207 #[test]
2208 #[serial_test::serial]
2209 fn refresh_stamps_the_version_onto_an_unshaped_path_without_creating_it() {
2210 let cfg = tempfile::tempdir().unwrap();
2211 let root = tempfile::tempdir().unwrap();
2212 temp_env(
2213 &[
2214 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2215 ("HOME", None),
2216 ],
2217 || {
2218 let path = root
2222 .path()
2223 .join(".agents")
2224 .join("skills")
2225 .join("bitbucket-cloud")
2226 .join("NOTES.md");
2227 save_state(&[Entry {
2228 path: path.clone(),
2229 agent: "agents".into(),
2230 kind: "file".into(),
2231 sha256: "abc".into(),
2232 version: "0.0.1".into(),
2233 skill: "bitbucket-cloud".into(),
2234 created: true,
2235 }])
2236 .unwrap();
2237
2238 let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2239 assert_eq!(
2240 outcomes.len(),
2241 0,
2242 "the shape guard never produces an outcome"
2243 );
2244 assert!(!path.exists(), "pruning/stamping must not create the file");
2245
2246 let (state, _) = load_state();
2247 assert_eq!(state.len(), 1, "the unshaped entry stays tracked");
2248 assert_eq!(state[0].version, env!("CARGO_PKG_VERSION"));
2249 assert!(!tracked_version_differs(&state));
2250 },
2251 );
2252 }
2253
2254 #[test]
2255 #[serial_test::serial]
2256 fn refresh_stamps_the_version_onto_an_unknown_skill_name_without_touching_the_file() {
2257 let cfg = tempfile::tempdir().unwrap();
2258 let root = tempfile::tempdir().unwrap();
2259 temp_env(
2260 &[
2261 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2262 ("HOME", None),
2263 ],
2264 || {
2265 let path = skill_file(root.path(), Agent::Agents, bb_skill());
2271 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2272 std::fs::write(&path, "whatever was here").unwrap();
2273 save_state(&[Entry {
2274 path: path.clone(),
2275 agent: "agents".into(),
2276 kind: "file".into(),
2277 sha256: "abc".into(),
2278 version: "0.0.1".into(),
2279 skill: "some-future-skill".into(),
2280 created: true,
2281 }])
2282 .unwrap();
2283
2284 let outcomes = refresh_tracked(MissingPolicy::Restore).unwrap();
2285 assert_eq!(
2286 outcomes.len(),
2287 0,
2288 "the unknown-skill skip never produces an outcome"
2289 );
2290 assert_eq!(
2291 std::fs::read_to_string(&path).unwrap(),
2292 "whatever was here",
2293 "the file must be left alone"
2294 );
2295
2296 let (state, _) = load_state();
2297 assert_eq!(state.len(), 1, "the unknown-skill entry stays tracked");
2298 assert_eq!(state[0].version, env!("CARGO_PKG_VERSION"));
2299 assert!(!tracked_version_differs(&state));
2300 },
2301 );
2302 }
2303
2304 #[test]
2309 fn ancestor_is_definitely_gone_is_true_only_for_not_found() {
2310 assert!(
2311 ancestor_is_definitely_gone(Path::new("/definitely/does/not/exist/anywhere-xyz")),
2312 "a path with no such component must read as definitely gone"
2313 );
2314
2315 let dir = tempfile::tempdir().unwrap();
2316 assert!(
2317 !ancestor_is_definitely_gone(dir.path()),
2318 "an existing directory must not read as gone"
2319 );
2320 }
2321
2322 #[test]
2331 #[cfg(unix)]
2332 fn ancestor_is_definitely_gone_is_false_for_a_permission_error() {
2333 use std::os::unix::fs::PermissionsExt;
2334
2335 let parent = tempfile::tempdir().unwrap();
2336 let locked = parent.path().join("locked");
2337 std::fs::create_dir(&locked).unwrap();
2338 let target = locked.join("child");
2339 std::fs::create_dir(&target).unwrap();
2340
2341 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
2342
2343 let stat_result = std::fs::symlink_metadata(&target);
2348 let restore = || {
2349 let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
2350 };
2351 if stat_result.is_ok() {
2352 restore();
2353 return;
2354 }
2355 let is_permission_error = matches!(
2356 &stat_result,
2357 Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied
2358 );
2359 if !is_permission_error {
2360 restore();
2363 return;
2364 }
2365
2366 assert!(
2367 !ancestor_is_definitely_gone(&target),
2368 "a permission error must not be treated as definitely gone"
2369 );
2370 restore();
2371 }
2372
2373 #[test]
2378 #[serial_test::serial]
2379 #[cfg(unix)]
2380 fn refresh_reports_failed_for_an_unwritable_entry_without_blocking_the_rest() {
2381 use std::os::unix::fs::PermissionsExt;
2382
2383 let writable_root = tempfile::tempdir().unwrap();
2384 let locked_root = tempfile::tempdir().unwrap();
2385 let cfg = tempfile::tempdir().unwrap();
2386 temp_env(
2387 &[
2388 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2389 ("HOME", None),
2390 ],
2391 || {
2392 let old = "---\nname: bitbucket-cloud\n---\nold text\n";
2393
2394 let writable_path = skill_file(writable_root.path(), Agent::Agents, bb_skill());
2395 std::fs::create_dir_all(writable_path.parent().unwrap()).unwrap();
2396 std::fs::write(&writable_path, old).unwrap();
2397
2398 let locked_path = skill_file(locked_root.path(), Agent::Agents, bb_skill());
2399 std::fs::create_dir_all(locked_path.parent().unwrap()).unwrap();
2400 std::fs::write(&locked_path, old).unwrap();
2401 std::fs::set_permissions(
2405 locked_path.parent().unwrap(),
2406 std::fs::Permissions::from_mode(0o000),
2407 )
2408 .unwrap();
2409
2410 let old_hash = content_hash(old.as_bytes());
2411 save_state(&[
2412 Entry {
2413 path: writable_path.clone(),
2414 agent: "agents".into(),
2415 kind: "file".into(),
2416 sha256: old_hash.clone(),
2417 version: "0.0.1".into(),
2418 skill: "bitbucket-cloud".into(),
2419 created: true,
2420 },
2421 Entry {
2422 path: locked_path.clone(),
2423 agent: "agents".into(),
2424 kind: "file".into(),
2425 sha256: old_hash.clone(),
2426 version: "0.0.1".into(),
2427 skill: "bitbucket-cloud".into(),
2428 created: true,
2429 },
2430 ])
2431 .unwrap();
2432
2433 let restore = || {
2434 let _ = std::fs::set_permissions(
2435 locked_path.parent().unwrap(),
2436 std::fs::Permissions::from_mode(0o755),
2437 );
2438 };
2439
2440 if std::fs::write(locked_path.parent().unwrap().join("probe"), "x").is_ok() {
2444 let _ = std::fs::remove_file(locked_path.parent().unwrap().join("probe"));
2445 restore();
2446 return;
2447 }
2448
2449 let result = refresh_tracked(MissingPolicy::Restore);
2450 restore();
2451 let outcomes = result.unwrap();
2452
2453 assert_eq!(outcomes.len(), 2);
2454 let writable_outcome = outcomes.iter().find(|o| o.path == writable_path).unwrap();
2455 assert_eq!(writable_outcome.action, Action::Refreshed);
2456 assert_eq!(
2457 std::fs::read_to_string(&writable_path).unwrap(),
2458 bb_skill().content,
2459 "the writable entry must still refresh despite the other one failing"
2460 );
2461
2462 let locked_outcome = outcomes.iter().find(|o| o.path == locked_path).unwrap();
2463 assert_eq!(locked_outcome.action, Action::Failed);
2464
2465 let (state, _) = load_state();
2466 let writable_entry = state.iter().find(|e| e.path == writable_path).unwrap();
2467 assert_eq!(
2468 writable_entry.version,
2469 env!("CARGO_PKG_VERSION"),
2470 "save_state must have persisted the entry that did succeed"
2471 );
2472 let locked_entry = state.iter().find(|e| e.path == locked_path).unwrap();
2473 assert_eq!(
2474 locked_entry.version, "0.0.1",
2475 "a failed write must not stamp the version — it is not current"
2476 );
2477 assert_eq!(
2478 locked_entry.sha256, old_hash,
2479 "a failed write must not update the recorded hash either"
2480 );
2481 },
2482 );
2483 }
2484
2485 #[test]
2491 #[serial_test::serial]
2492 fn refresh_with_preserve_leaves_a_missing_file_missing() {
2493 let cfg = tempfile::tempdir().unwrap();
2494 let root = tempfile::tempdir().unwrap();
2495 temp_env(
2496 &[
2497 ("XDG_CONFIG_HOME", Some(cfg.path().to_str().unwrap())),
2498 ("HOME", None),
2499 ],
2500 || {
2501 let path = skill_file(root.path(), Agent::Agents, bb_skill());
2502 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2503 save_state(&[Entry {
2504 path: path.clone(),
2505 agent: "agents".into(),
2506 kind: "file".into(),
2507 sha256: content_hash(bb_skill().content.as_bytes()),
2508 version: "0.0.1".into(),
2509 skill: "bitbucket-cloud".into(),
2510 created: true,
2511 }])
2512 .unwrap();
2513
2514 let outcomes = refresh_tracked(MissingPolicy::Preserve).unwrap();
2515 assert!(
2516 outcomes.is_empty(),
2517 "a preserved missing entry must produce no outcome"
2518 );
2519 assert!(!path.exists(), "the file must stay deleted");
2520
2521 let (state, _) = load_state();
2522 assert_eq!(state.len(), 1, "the entry stays tracked");
2523 assert_eq!(
2524 state[0].version, "0.0.1",
2525 "an untouched entry must not be stamped as current"
2526 );
2527 },
2528 );
2529 }
2530}