Skip to main content

mur_common/skill/
registry.rs

1//! Git-based skill registry data model for index.yaml.
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct RegistryIndex {
8    #[serde(default)]
9    pub schema_version: u32,
10    #[serde(default)]
11    pub updated_at: String,
12    #[serde(default)]
13    pub skills: BTreeMap<String, RegistrySkillEntry>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct RegistrySkillEntry {
18    pub latest: String,
19    pub description: String,
20    pub publisher: String,
21    pub category: String,
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub tags: Vec<String>,
24    #[serde(default)]
25    pub content_sha256: String,
26    #[serde(default)]
27    pub install_count: u64,
28}
29
30impl RegistryIndex {
31    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
32        serde_yaml_ng::from_str(yaml)
33    }
34
35    pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
36        serde_yaml_ng::to_string(self)
37    }
38
39    pub fn search(&self, query: &str) -> Vec<(&str, &RegistrySkillEntry)> {
40        let q = query.to_lowercase();
41        let mut results: Vec<_> = self
42            .skills
43            .iter()
44            .filter(|(name, e)| {
45                name.to_lowercase().contains(&q)
46                    || e.description.to_lowercase().contains(&q)
47                    || e.tags.iter().any(|t| t.to_lowercase().contains(&q))
48            })
49            .map(|(n, e)| (n.as_str(), e))
50            .collect();
51        results.sort_by_key(|k| std::cmp::Reverse(k.1.install_count));
52        results
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    const SAMPLE: &str = r#"
61schema_version: 1
62updated_at: 2026-05-25T00:00:00Z
63skills:
64  research-prices:
65    latest: 1.1.0
66    description: Search and compare product prices
67    publisher: human:david
68    category: workflow
69    tags: [e-commerce, price]
70    content_sha256: "abcd"
71    install_count: 42
72  web-browsing:
73    latest: 2.0.0
74    description: Browse web pages
75    publisher: human:david
76    category: workflow
77    tags: [web, browser]
78    content_sha256: "1234"
79    install_count: 128
80"#;
81
82    #[test]
83    fn parses_index() {
84        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
85        assert_eq!(idx.skills.len(), 2);
86        assert_eq!(idx.skills["research-prices"].latest, "1.1.0");
87    }
88
89    #[test]
90    fn search_finds_by_name() {
91        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
92        assert_eq!(idx.search("price").len(), 1);
93    }
94
95    #[test]
96    fn search_finds_by_tag() {
97        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
98        assert_eq!(idx.search("browser").len(), 1);
99    }
100
101    #[test]
102    fn search_orders_by_install_count() {
103        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
104        let r = idx.search("a");
105        assert_eq!(r.len(), 2);
106        assert_eq!(r[0].0, "web-browsing");
107    }
108
109    #[test]
110    fn empty_query_returns_all() {
111        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
112        assert_eq!(idx.search("").len(), 2);
113    }
114
115    #[test]
116    fn no_match_returns_empty() {
117        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
118        assert!(idx.search("zzz").is_empty());
119    }
120
121    #[test]
122    fn roundtrip_yaml() {
123        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
124        let yaml = idx.to_yaml().unwrap();
125        let idx2 = RegistryIndex::from_yaml(&yaml).unwrap();
126        assert_eq!(idx.skills.len(), idx2.skills.len());
127    }
128}