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