1use std::collections::HashMap;
33
34use crate::family::FamilyMeta;
35use crate::skill::Skill;
36
37#[derive(Debug, Clone)]
39pub enum SkillCapability {
40 Ready,
42 Unavailable {
45 reason: String,
48 hint: Option<String>,
51 },
52}
53
54impl SkillCapability {
55 pub fn unavailable(reason: impl Into<String>, hint: impl Into<String>) -> Self {
57 Self::Unavailable {
58 reason: reason.into(),
59 hint: Some(hint.into()),
60 }
61 }
62
63 pub fn unavailable_no_hint(reason: impl Into<String>) -> Self {
66 Self::Unavailable {
67 reason: reason.into(),
68 hint: None,
69 }
70 }
71
72 pub fn is_ready(&self) -> bool {
74 matches!(self, Self::Ready)
75 }
76}
77
78pub fn combine(family: &SkillCapability, skill: &SkillCapability) -> SkillCapability {
83 match family {
84 SkillCapability::Unavailable { .. } => family.clone(),
85 SkillCapability::Ready => skill.clone(),
86 }
87}
88
89#[derive(Debug, Clone, Default)]
94pub struct Capabilities {
95 tools: HashMap<&'static str, SkillCapability>,
96 families: HashMap<&'static str, SkillCapability>,
97}
98
99impl Capabilities {
100 pub fn resolved(&self, tool: &str) -> SkillCapability {
103 self.tools
104 .get(tool)
105 .cloned()
106 .unwrap_or(SkillCapability::Ready)
107 }
108
109 pub fn is_ready(&self, tool: &str) -> bool {
111 self.tools.get(tool).map_or(true, |c| c.is_ready())
112 }
113
114 pub fn tools(&self) -> &HashMap<&'static str, SkillCapability> {
116 &self.tools
117 }
118
119 pub fn families(&self) -> &HashMap<&'static str, SkillCapability> {
121 &self.families
122 }
123
124 pub fn unavailable_tools(&self) -> Vec<(&'static str, &SkillCapability)> {
127 self.tools
128 .iter()
129 .filter(|(_, c)| !c.is_ready())
130 .map(|(n, c)| (*n, c))
131 .collect()
132 }
133
134 pub fn unavailable_families(&self) -> Vec<(&'static str, &SkillCapability)> {
136 self.families
137 .iter()
138 .filter(|(_, c)| !c.is_ready())
139 .map(|(n, c)| (*n, c))
140 .collect()
141 }
142}
143
144pub fn resolve<S: 'static>(
150 families: &[Box<dyn FamilyMeta>],
151 skills: &[Box<dyn Skill<S>>],
152) -> Capabilities {
153 let family_caps: HashMap<&'static str, SkillCapability> = families
156 .iter()
157 .map(|f| (f.family(), f.check_capability()))
158 .collect();
159
160 let mut tool_to_family: HashMap<&'static str, &'static str> = HashMap::new();
161 for fam in families {
162 let name = fam.family();
163 for t in fam.tools() {
164 tool_to_family.insert(t, name);
165 }
166 }
167
168 let mut tools: HashMap<&'static str, SkillCapability> = HashMap::new();
169 for skill in skills {
170 let tool_name = skill.name();
171 let family_cap = tool_to_family
172 .get(tool_name)
173 .and_then(|f| family_caps.get(f))
174 .cloned()
175 .unwrap_or(SkillCapability::Ready);
176 tools.insert(tool_name, combine(&family_cap, &skill.check_capability()));
177 }
178
179 Capabilities {
180 tools,
181 families: family_caps,
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use crate::skill::{schema_for, NoArgs, SkillCtx};
189 use futures::future::BoxFuture;
190 use rmcp::model::{CallToolResult, JsonObject};
191 use rmcp::ErrorData as McpError;
192 use std::sync::Arc;
193
194 struct TestSkill {
195 name: &'static str,
196 cap: SkillCapability,
197 }
198 impl Skill<()> for TestSkill {
199 fn name(&self) -> &'static str {
200 self.name
201 }
202 fn description(&self) -> &'static str {
203 "test"
204 }
205 fn schema(&self) -> Arc<JsonObject> {
206 schema_for::<NoArgs>()
207 }
208 fn check_capability(&self) -> SkillCapability {
209 self.cap.clone()
210 }
211 fn call<'a>(
212 &self,
213 _ctx: SkillCtx<'a, ()>,
214 ) -> BoxFuture<'a, Result<CallToolResult, McpError>> {
215 Box::pin(async move { Ok(crate::text_result("ok")) })
216 }
217 }
218
219 struct TestFamily {
220 name: &'static str,
221 tools: &'static [&'static str],
222 cap: SkillCapability,
223 }
224 impl FamilyMeta for TestFamily {
225 fn family(&self) -> &'static str {
226 self.name
227 }
228 fn tools(&self) -> Vec<&'static str> {
229 self.tools.to_vec()
230 }
231 fn description(&self) -> &'static str {
232 "test family"
233 }
234 fn check_capability(&self) -> SkillCapability {
235 self.cap.clone()
236 }
237 }
238
239 #[test]
240 fn ready_is_ready() {
241 assert!(SkillCapability::Ready.is_ready());
242 }
243
244 #[test]
245 fn unavailable_carries_reason_and_hint() {
246 let c = SkillCapability::unavailable("no socket", "mount it");
247 assert!(!c.is_ready());
248 match c {
249 SkillCapability::Unavailable { reason, hint } => {
250 assert_eq!(reason, "no socket");
251 assert_eq!(hint.as_deref(), Some("mount it"));
252 }
253 _ => panic!(),
254 }
255 }
256
257 #[test]
258 fn unavailable_no_hint_omits_hint() {
259 match SkillCapability::unavailable_no_hint("x86 only") {
260 SkillCapability::Unavailable { hint, .. } => assert!(hint.is_none()),
261 _ => panic!(),
262 }
263 }
264
265 #[test]
266 fn combine_family_unavailable_wins() {
267 let fam = SkillCapability::unavailable("no socket", "mount it");
268 match combine(&fam, &SkillCapability::unavailable_no_hint("other")) {
271 SkillCapability::Unavailable { reason, hint } => {
272 assert_eq!(reason, "no socket");
273 assert_eq!(hint.as_deref(), Some("mount it"));
274 }
275 _ => panic!(),
276 }
277 }
278
279 #[test]
280 fn combine_skill_applies_when_family_ready() {
281 let merged = combine(
282 &SkillCapability::Ready,
283 &SkillCapability::unavailable_no_hint("x86 only"),
284 );
285 assert!(!merged.is_ready());
286 assert!(combine(&SkillCapability::Ready, &SkillCapability::Ready).is_ready());
288 }
289
290 #[test]
291 fn resolve_propagates_family_and_tool_probes() {
292 let families: Vec<Box<dyn FamilyMeta>> = vec![
293 Box::new(TestFamily {
294 name: "docker",
295 tools: &["docker_ps", "docker_run"],
296 cap: SkillCapability::unavailable("daemon unreachable", "start docker"),
297 }),
298 Box::new(TestFamily {
299 name: "math",
300 tools: &["add"],
301 cap: SkillCapability::Ready,
302 }),
303 ];
304 let skills: Vec<Box<dyn Skill<()>>> = vec![
305 Box::new(TestSkill {
306 name: "docker_ps",
307 cap: SkillCapability::Ready,
308 }),
309 Box::new(TestSkill {
310 name: "docker_run",
311 cap: SkillCapability::Ready,
312 }),
313 Box::new(TestSkill {
314 name: "add",
315 cap: SkillCapability::Ready,
316 }),
317 Box::new(TestSkill {
319 name: "gpu_only",
320 cap: SkillCapability::unavailable_no_hint("needs an NVIDIA GPU"),
321 }),
322 ];
323 let caps = resolve(&families, &skills);
324
325 assert!(!caps.is_ready("docker_ps"));
328 assert!(!caps.is_ready("docker_run"));
329 match caps.resolved("docker_ps") {
330 SkillCapability::Unavailable { reason, .. } => {
331 assert_eq!(reason, "daemon unreachable");
332 }
333 _ => panic!("family Unavailable should propagate to its tools"),
334 }
335 assert!(caps.is_ready("add"));
337 assert!(!caps.is_ready("gpu_only"));
339 assert!(caps.is_ready("not_registered"));
341
342 assert_eq!(caps.unavailable_tools().len(), 3);
343 assert_eq!(caps.unavailable_families().len(), 1);
344 }
345}