1use serde::{Deserialize, Serialize};
11use std::str::FromStr;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
16pub enum SkillCapability {
17 ReadFile,
18 ListTools,
19 Search,
20 WriteFile,
21 ExecuteSafe,
22 NetworkHttp,
23}
24
25impl SkillCapability {
26 pub const ALL: &[SkillCapability] = &[
27 SkillCapability::ReadFile,
28 SkillCapability::ListTools,
29 SkillCapability::Search,
30 SkillCapability::WriteFile,
31 SkillCapability::ExecuteSafe,
32 SkillCapability::NetworkHttp,
33 ];
34
35 pub fn as_str(self) -> &'static str {
36 match self {
37 SkillCapability::ReadFile => "read_file",
38 SkillCapability::ListTools => "list_tools",
39 SkillCapability::Search => "search",
40 SkillCapability::WriteFile => "write_file",
41 SkillCapability::ExecuteSafe => "execute_safe",
42 SkillCapability::NetworkHttp => "network_http",
43 }
44 }
45}
46
47impl std::fmt::Display for SkillCapability {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 f.write_str(self.as_str())
50 }
51}
52
53impl FromStr for SkillCapability {
54 type Err = ParseCapabilityError;
55
56 fn from_str(s: &str) -> Result<Self, Self::Err> {
57 Ok(match s {
58 "read_file" => SkillCapability::ReadFile,
59 "list_tools" => SkillCapability::ListTools,
60 "search" => SkillCapability::Search,
61 "write_file" => SkillCapability::WriteFile,
62 "execute_safe" => SkillCapability::ExecuteSafe,
63 "network_http" => SkillCapability::NetworkHttp,
64 other => return Err(ParseCapabilityError(other.to_string())),
65 })
66 }
67}
68
69#[derive(Debug, thiserror::Error)]
70#[error(
71 "unknown MCP capability '{0}' (expected one of: read_file, list_tools, search, \
72 write_file, execute_safe, network_http)"
73)]
74pub struct ParseCapabilityError(pub String);
75
76impl Serialize for SkillCapability {
79 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
80 s.serialize_str(self.as_str())
81 }
82}
83
84impl<'de> Deserialize<'de> for SkillCapability {
85 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
86 let s = String::deserialize(d)?;
87 s.parse().map_err(serde::de::Error::custom)
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94pub struct McpRequirement {
95 pub tool_pattern: String,
98
99 pub capability: SkillCapability,
102
103 #[serde(default, skip_serializing_if = "String::is_empty")]
106 pub fallback: String,
107}
108
109pub fn validate_requirements(reqs: &[McpRequirement]) -> Result<(), (usize, String)> {
114 use std::collections::HashSet;
115 let mut seen: HashSet<(&str, &str)> = HashSet::new();
116 for (i, req) in reqs.iter().enumerate() {
117 if req.tool_pattern.is_empty() {
118 return Err((i, "tool_pattern must not be empty".into()));
119 }
120 if globset::Glob::new(&req.tool_pattern).is_err() {
122 return Err((i, format!("invalid glob pattern: '{}'", req.tool_pattern)));
123 }
124 let cap_str = req.capability.as_str();
125 if !seen.insert((&req.tool_pattern, cap_str)) {
126 return Err((
127 i,
128 format!(
129 "duplicate (tool_pattern, capability) pair: '{}'/{}",
130 req.tool_pattern, req.capability
131 ),
132 ));
133 }
134 }
135 Ok(())
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn parse_valid_capabilities() {
144 assert_eq!(
145 "read_file".parse::<SkillCapability>().unwrap(),
146 SkillCapability::ReadFile
147 );
148 assert_eq!(
149 "list_tools".parse::<SkillCapability>().unwrap(),
150 SkillCapability::ListTools
151 );
152 assert_eq!(
153 "search".parse::<SkillCapability>().unwrap(),
154 SkillCapability::Search
155 );
156 assert_eq!(
157 "write_file".parse::<SkillCapability>().unwrap(),
158 SkillCapability::WriteFile
159 );
160 assert_eq!(
161 "execute_safe".parse::<SkillCapability>().unwrap(),
162 SkillCapability::ExecuteSafe
163 );
164 assert_eq!(
165 "network_http".parse::<SkillCapability>().unwrap(),
166 SkillCapability::NetworkHttp
167 );
168 }
169
170 #[test]
171 fn parse_invalid_capability() {
172 let err = "telepathy".parse::<SkillCapability>().unwrap_err();
173 assert!(err.to_string().contains("unknown MCP capability"));
174 assert!(err.to_string().contains("telepathy"));
175 }
176
177 #[test]
178 fn capability_display_roundtrips() {
179 for cap in SkillCapability::ALL {
180 let s = cap.to_string();
181 let parsed: SkillCapability = s.parse().unwrap();
182 assert_eq!(parsed, *cap);
183 }
184 }
185
186 #[test]
187 fn capability_serializes_as_string() {
188 let json = serde_json::to_string(&SkillCapability::ReadFile).unwrap();
189 assert_eq!(json, "\"read_file\"");
190 }
191
192 #[test]
193 fn capability_deserializes_from_string() {
194 let cap: SkillCapability = serde_json::from_str("\"network_http\"").unwrap();
195 assert_eq!(cap, SkillCapability::NetworkHttp);
196 }
197
198 #[test]
199 fn capability_rejects_unknown_string() {
200 let err = serde_json::from_str::<SkillCapability>("\"telepathy\"").unwrap_err();
201 assert!(err.to_string().contains("unknown MCP capability"));
202 }
203
204 #[test]
205 fn validate_empty_requirements() {
206 assert!(validate_requirements(&[]).is_ok());
207 }
208
209 #[test]
210 fn validate_single_requirement() {
211 let reqs = vec![McpRequirement {
212 tool_pattern: "browser.*".into(),
213 capability: SkillCapability::NetworkHttp,
214 fallback: String::new(),
215 }];
216 assert!(validate_requirements(&reqs).is_ok());
217 }
218
219 #[test]
220 fn validate_rejects_empty_pattern() {
221 let reqs = vec![McpRequirement {
222 tool_pattern: String::new(),
223 capability: SkillCapability::ReadFile,
224 fallback: String::new(),
225 }];
226 let err = validate_requirements(&reqs).unwrap_err();
227 assert_eq!(err.0, 0);
228 assert!(err.1.contains("tool_pattern must not be empty"));
229 }
230
231 #[test]
232 fn validate_rejects_duplicate() {
233 let reqs = vec![
234 McpRequirement {
235 tool_pattern: "browser.*".into(),
236 capability: SkillCapability::NetworkHttp,
237 fallback: String::new(),
238 },
239 McpRequirement {
240 tool_pattern: "browser.*".into(),
241 capability: SkillCapability::NetworkHttp,
242 fallback: String::new(),
243 },
244 ];
245 let err = validate_requirements(&reqs).unwrap_err();
246 assert_eq!(err.0, 1);
247 assert!(err.1.contains("duplicate"));
248 }
249
250 #[test]
251 fn validate_allows_same_pattern_different_capability() {
252 let reqs = vec![
253 McpRequirement {
254 tool_pattern: "fs.*".into(),
255 capability: SkillCapability::ReadFile,
256 fallback: String::new(),
257 },
258 McpRequirement {
259 tool_pattern: "fs.*".into(),
260 capability: SkillCapability::WriteFile,
261 fallback: String::new(),
262 },
263 ];
264 assert!(validate_requirements(&reqs).is_ok());
265 }
266}