mur_common/skill/
registry.rs1use 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}