1use std::collections::BTreeMap;
9
10use crate::skill::{Category, SkillSummary};
11
12pub fn canonical_skill_name(name: &str) -> &str {
21 name.strip_prefix("devboy-").unwrap_or(name)
22}
23
24#[derive(Debug, Clone, Default)]
26pub struct Catalog {
27 entries: Vec<SkillSummary>,
28}
29
30impl Catalog {
31 pub fn from_summaries(mut summaries: Vec<SkillSummary>) -> Self {
36 let mut by_name: BTreeMap<String, SkillSummary> = BTreeMap::new();
38 for s in summaries.drain(..) {
39 by_name.insert(s.name.clone(), s);
40 }
41 let mut entries: Vec<SkillSummary> = by_name.into_values().collect();
42 entries.sort_by(|a, b| (a.category, &a.name).cmp(&(b.category, &b.name)));
43 Self { entries }
44 }
45
46 pub fn len(&self) -> usize {
48 self.entries.len()
49 }
50
51 pub fn is_empty(&self) -> bool {
53 self.entries.is_empty()
54 }
55
56 pub fn iter(&self) -> impl Iterator<Item = &SkillSummary> {
58 self.entries.iter()
59 }
60
61 pub fn by_category(&self, category: Category) -> impl Iterator<Item = &SkillSummary> {
63 self.entries.iter().filter(move |s| s.category == category)
64 }
65
66 pub fn get(&self, name: &str) -> Option<&SkillSummary> {
71 if let Some(direct) = self.entries.iter().find(|s| s.name == name) {
72 return Some(direct);
73 }
74 let canonical = canonical_skill_name(name);
75 self.entries
76 .iter()
77 .find(|s| canonical_skill_name(&s.name) == canonical)
78 }
79
80 pub fn counts_per_category(&self) -> BTreeMap<Category, usize> {
82 let mut out: BTreeMap<Category, usize> = BTreeMap::new();
83 for s in &self.entries {
84 *out.entry(s.category).or_insert(0) += 1;
85 }
86 out
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 fn sum(name: &str, cat: Category, version: u32) -> SkillSummary {
95 SkillSummary {
96 name: name.to_string(),
97 category: cat,
98 version,
99 description: format!("desc {name}"),
100 }
101 }
102
103 #[test]
104 fn catalog_sorts_by_category_then_name() {
105 let cat = Catalog::from_summaries(vec![
106 sum("b", Category::IssueTracking, 1),
107 sum("a", Category::SelfBootstrap, 1),
108 sum("c", Category::SelfBootstrap, 1),
109 ]);
110 let names: Vec<&str> = cat.iter().map(|s| s.name.as_str()).collect();
111 assert_eq!(names, vec!["a", "c", "b"]);
112 }
113
114 #[test]
115 fn catalog_deduplicates_by_name_keeping_last() {
116 let cat = Catalog::from_summaries(vec![
117 sum("a", Category::SelfBootstrap, 1),
118 sum("a", Category::SelfBootstrap, 7), ]);
120 assert_eq!(cat.len(), 1);
121 assert_eq!(cat.get("a").unwrap().version, 7);
122 }
123
124 #[test]
125 fn catalog_filters_by_category() {
126 let cat = Catalog::from_summaries(vec![
127 sum("a", Category::SelfBootstrap, 1),
128 sum("b", Category::IssueTracking, 1),
129 sum("c", Category::SelfBootstrap, 1),
130 ]);
131 let only: Vec<&str> = cat
132 .by_category(Category::SelfBootstrap)
133 .map(|s| s.name.as_str())
134 .collect();
135 assert_eq!(only, vec!["a", "c"]);
136 }
137
138 #[test]
139 fn canonical_skill_name_strips_devboy_prefix() {
140 assert_eq!(canonical_skill_name("setup"), "setup");
141 assert_eq!(canonical_skill_name("setup"), "setup");
142 assert_eq!(canonical_skill_name("analyze-usage"), "analyze-usage");
143 assert_eq!(canonical_skill_name("devboy-devboy-foo"), "devboy-foo");
145 }
146
147 #[test]
148 fn get_resolves_plugin_alias_for_legacy_entry() {
149 let cat = Catalog::from_summaries(vec![sum("setup", Category::SelfBootstrap, 2)]);
150 assert_eq!(cat.get("setup").unwrap().name, "setup");
152 assert_eq!(cat.get("setup").unwrap().name, "setup");
154 assert!(cat.get("not-a-skill").is_none());
156 }
157
158 #[test]
159 fn get_resolves_legacy_alias_for_plugin_entry() {
160 let cat = Catalog::from_summaries(vec![sum("setup", Category::SelfBootstrap, 2)]);
163 assert_eq!(cat.get("setup").unwrap().name, "setup");
164 assert_eq!(cat.get("setup").unwrap().name, "setup");
165 }
166
167 #[test]
168 fn counts_per_category_is_accurate() {
169 let cat = Catalog::from_summaries(vec![
170 sum("a", Category::SelfBootstrap, 1),
171 sum("b", Category::SelfBootstrap, 1),
172 sum("c", Category::IssueTracking, 1),
173 ]);
174 let counts = cat.counts_per_category();
175 assert_eq!(counts[&Category::SelfBootstrap], 2);
176 assert_eq!(counts[&Category::IssueTracking], 1);
177 }
178}