mur_common/skill/
resolve.rs1use crate::skill::inventory::McpInventory;
2use crate::skill::manifest::ProcedureStep;
3use crate::skill::mcp::{McpRequirement, SkillCapability};
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub enum Resolution {
7 Literal { tool: String },
9 Hint { tool: String },
11 IntentMatch {
13 tool: String,
14 capability: SkillCapability,
15 },
16 Fallback {
18 tool: String,
19 capability: SkillCapability,
20 },
21 Unresolved { reason: String },
23}
24
25impl Resolution {
26 pub fn picked_tool(&self) -> Option<&str> {
27 match self {
28 Resolution::Literal { tool }
29 | Resolution::Hint { tool }
30 | Resolution::IntentMatch { tool, .. }
31 | Resolution::Fallback { tool, .. } => Some(tool),
32 Resolution::Unresolved { .. } => None,
33 }
34 }
35
36 pub fn source_tag(&self) -> &'static str {
37 match self {
38 Resolution::Literal { .. } => "literal",
39 Resolution::Hint { .. } => "hint",
40 Resolution::IntentMatch { .. } => "intent_match",
41 Resolution::Fallback { .. } => "fallback",
42 Resolution::Unresolved { .. } => "unresolved",
43 }
44 }
45}
46
47pub fn resolve_step(
57 step: &ProcedureStep,
58 requirements: &[McpRequirement],
59 inventory: &McpInventory,
60) -> Resolution {
61 if step.intent.is_none() {
63 if let Some(t) = step.tool.as_deref() {
64 return Resolution::Literal {
65 tool: t.to_string(),
66 };
67 }
68 return Resolution::Unresolved {
69 reason: "step has neither tool nor intent".into(),
70 };
71 }
72
73 if let Some(hint) = step.tool_hint.as_deref()
75 && let Some(t) = match_in_inventory(hint, inventory)
76 {
77 return Resolution::Hint { tool: t };
78 }
79
80 let mut best: Option<(String, SkillCapability)> = None;
82 for req in requirements {
83 let Ok(glob) = globset::Glob::new(&req.tool_pattern) else {
84 continue;
85 };
86 let matcher = glob.compile_matcher();
87 let mut candidates: Vec<&str> = inventory.iter().filter(|t| matcher.is_match(t)).collect();
88 if candidates.is_empty() {
89 continue;
90 }
91 candidates.sort_by(|a, b| a.len().cmp(&b.len()).then(a.cmp(b)));
93 let pick = candidates[0].to_string();
94 best = Some((pick, req.capability));
95 break;
96 }
97 if let Some((tool, cap)) = best {
98 return Resolution::IntentMatch {
99 tool,
100 capability: cap,
101 };
102 }
103
104 for req in requirements {
106 if req.fallback.is_empty() {
107 continue;
108 }
109 if inventory.contains(&req.fallback) {
110 return Resolution::Fallback {
111 tool: req.fallback.clone(),
112 capability: req.capability,
113 };
114 }
115 }
116
117 Resolution::Unresolved {
119 reason: format!(
120 "intent '{}' has no matching tool in inventory ({} tools, {} requirements)",
121 step.intent.as_deref().unwrap_or(""),
122 inventory.iter().count(),
123 requirements.len(),
124 ),
125 }
126}
127
128fn match_in_inventory(pattern: &str, inv: &McpInventory) -> Option<String> {
129 if pattern.contains('*') {
130 let Ok(g) = globset::Glob::new(pattern) else {
131 return None;
132 };
133 let m = g.compile_matcher();
134 let mut hits: Vec<&str> = inv.iter().filter(|t| m.is_match(t)).collect();
135 hits.sort_by(|a, b| a.len().cmp(&b.len()).then(a.cmp(b)));
136 hits.first().map(|s| s.to_string())
137 } else {
138 inv.contains(pattern).then(|| pattern.to_string())
139 }
140}