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, PartialEq, 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, PartialEq, 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    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub recommended_roles: Vec<String>,
30}
31
32impl RegistryIndex {
33    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
34        serde_yaml_ng::from_str(yaml)
35    }
36
37    pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
38        serde_yaml_ng::to_string(self)
39    }
40
41    pub fn search(&self, query: &str) -> Vec<(&str, &RegistrySkillEntry)> {
42        let q = query.to_lowercase();
43        let mut results: Vec<_> = self
44            .skills
45            .iter()
46            .filter(|(name, e)| {
47                name.to_lowercase().contains(&q)
48                    || e.description.to_lowercase().contains(&q)
49                    || e.tags.iter().any(|t| t.to_lowercase().contains(&q))
50            })
51            .map(|(n, e)| (n.as_str(), e))
52            .collect();
53        results.sort_by_key(|k| std::cmp::Reverse(k.1.install_count));
54        results
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    const SAMPLE: &str = r#"
63schema_version: 1
64updated_at: 2026-05-25T00:00:00Z
65skills:
66  research-prices:
67    latest: 1.1.0
68    description: Search and compare product prices
69    publisher: human:david
70    category: workflow
71    tags: [e-commerce, price]
72    content_sha256: "abcd"
73    install_count: 42
74  web-browsing:
75    latest: 2.0.0
76    description: Browse web pages
77    publisher: human:david
78    category: workflow
79    tags: [web, browser]
80    content_sha256: "1234"
81    install_count: 128
82"#;
83
84    #[test]
85    fn parses_index() {
86        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
87        assert_eq!(idx.skills.len(), 2);
88        assert_eq!(idx.skills["research-prices"].latest, "1.1.0");
89    }
90
91    #[test]
92    fn search_finds_by_name() {
93        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
94        assert_eq!(idx.search("price").len(), 1);
95    }
96
97    #[test]
98    fn search_finds_by_tag() {
99        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
100        assert_eq!(idx.search("browser").len(), 1);
101    }
102
103    #[test]
104    fn search_orders_by_install_count() {
105        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
106        let r = idx.search("a");
107        assert_eq!(r.len(), 2);
108        assert_eq!(r[0].0, "web-browsing");
109    }
110
111    #[test]
112    fn empty_query_returns_all() {
113        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
114        assert_eq!(idx.search("").len(), 2);
115    }
116
117    #[test]
118    fn no_match_returns_empty() {
119        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
120        assert!(idx.search("zzz").is_empty());
121    }
122
123    #[test]
124    fn recommended_roles_parses_and_defaults_empty() {
125        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
126        assert!(idx.skills["research-prices"].recommended_roles.is_empty());
127
128        let with_roles = RegistryIndex::from_yaml(
129            r#"
130skills:
131  writing-plans:
132    latest: 1.0.0
133    description: d
134    publisher: mur-official
135    category: workflow
136    recommended_roles: [pm]
137"#,
138        )
139        .unwrap();
140        assert_eq!(
141            with_roles.skills["writing-plans"].recommended_roles,
142            vec!["pm".to_string()]
143        );
144    }
145
146    #[test]
147    fn roundtrip_yaml() {
148        let idx = RegistryIndex::from_yaml(SAMPLE).unwrap();
149        let yaml = idx.to_yaml().unwrap();
150        let idx2 = RegistryIndex::from_yaml(&yaml).unwrap();
151        assert_eq!(idx.skills.len(), idx2.skills.len());
152    }
153}