Skip to main content

devboy_skills/
catalog.rs

1//! In-memory catalogue of skill summaries — supports filtering by
2//! category, fuzzy name search, and simple counting.
3//!
4//! A [`Catalog`] is the normalised output of [`crate::source::SkillSource::list`];
5//! the CLI builds one at every invocation so that downstream commands
6//! never have to re-ask the source about what is installable.
7
8use std::collections::BTreeMap;
9
10use crate::skill::{Category, SkillSummary};
11
12/// Fold the legacy `devboy-` prefix when callers still use it.
13///
14/// Skill source files were renamed in 0.25 to drop the `devboy-` prefix
15/// (see ADR-018 §3 and `skills/PLUGIN_NAMING.md`). For one or two
16/// releases we keep accepting the legacy form so external callers
17/// (scripted installs, dotfile copies, AGENTS.md cheat-sheets) keep
18/// resolving. `Catalog::get("devboy-setup")` returns the same entry as
19/// `Catalog::get("setup")`.
20pub fn canonical_skill_name(name: &str) -> &str {
21    name.strip_prefix("devboy-").unwrap_or(name)
22}
23
24/// Sorted, filterable view over a set of skill summaries.
25#[derive(Debug, Clone, Default)]
26pub struct Catalog {
27    entries: Vec<SkillSummary>,
28}
29
30impl Catalog {
31    /// Build a catalogue from a raw list of summaries. Duplicates (by
32    /// name) are silently deduplicated — the last occurrence wins, so
33    /// layered sources can override earlier entries by simply appearing
34    /// later in the composition order.
35    pub fn from_summaries(mut summaries: Vec<SkillSummary>) -> Self {
36        // Deduplicate by name, keeping the last occurrence.
37        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    /// Total number of skills after deduplication.
47    pub fn len(&self) -> usize {
48        self.entries.len()
49    }
50
51    /// Whether the catalogue contains no skills.
52    pub fn is_empty(&self) -> bool {
53        self.entries.is_empty()
54    }
55
56    /// Iterate over every skill in catalogue order (category, then name).
57    pub fn iter(&self) -> impl Iterator<Item = &SkillSummary> {
58        self.entries.iter()
59    }
60
61    /// Iterate over the skills in a specific category.
62    pub fn by_category(&self, category: Category) -> impl Iterator<Item = &SkillSummary> {
63        self.entries.iter().filter(move |s| s.category == category)
64    }
65
66    /// Look a skill up by exact name or by the legacy `devboy-` form.
67    ///
68    /// Both `get("setup")` and `get("devboy-setup")` resolve to the
69    /// same entry — see [`canonical_skill_name`].
70    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    /// Return every (category, count) pair for skills in the catalogue.
81    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), // wins
119        ]);
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        // Only one prefix is stripped — guard against repeated trimming.
144        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        // Plugin-style query without prefix finds the legacy entry.
151        assert_eq!(cat.get("setup").unwrap().name, "setup");
152        // Exact legacy query still works.
153        assert_eq!(cat.get("setup").unwrap().name, "setup");
154        // Unrelated query returns None.
155        assert!(cat.get("not-a-skill").is_none());
156    }
157
158    #[test]
159    fn get_resolves_legacy_alias_for_plugin_entry() {
160        // If a future source ships skills already under the plugin name
161        // (e.g., a marketplace overlay), legacy queries must still hit.
162        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}