use std::collections::HashSet;
use crate::registry::ToolRegistry;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedToolName {
pub original: String,
pub canonical: String,
pub repaired: bool,
}
pub fn strip_tool_name_pollution(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return String::new();
}
let mut name = trimmed.to_string();
for sep in ['"', '\'', '<', '>'] {
if let Some(idx) = name.find(sep)
&& idx > 0
{
name.truncate(idx);
}
}
let base = if let Some(pos) = name.find("<|") {
name[..pos].trim_end()
} else {
name.trim_end()
};
base.replace([' ', '-'], "_")
}
fn norm_separators(s: &str) -> String {
s.to_lowercase().replace(['-', ' '], "_")
}
fn camel_to_snake(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() {
if i > 0 {
out.push('_');
}
out.extend(c.to_lowercase());
} else {
out.push(c);
}
}
out
}
fn strip_tool_suffix(s: &str) -> Option<String> {
let lc = s.to_lowercase();
for suffix in ["_tool", "-tool", "tool"] {
if lc.ends_with(suffix) {
let end = s.len().saturating_sub(suffix.len());
return Some(s[..end].trim_end_matches(['_', '-']).to_string());
}
}
None
}
pub fn repair_tool_name(registry: &ToolRegistry, raw: &str) -> Option<String> {
let cleaned = strip_tool_name_pollution(raw);
if cleaned.is_empty() {
return None;
}
if let Some(canonical) = registry.lookup_tool_name(&cleaned) {
return Some(canonical);
}
let lowered = cleaned.to_lowercase();
if let Some(canonical) = registry.lookup_tool_name(&lowered) {
return Some(canonical);
}
let normalized = norm_separators(&cleaned);
if let Some(canonical) = registry.lookup_tool_name(&normalized) {
return Some(canonical);
}
let mut candidates: HashSet<String> = HashSet::new();
candidates.insert(cleaned.clone());
candidates.insert(lowered.clone());
candidates.insert(normalized.clone());
candidates.insert(camel_to_snake(&cleaned));
for _ in 0..2 {
let mut extra = HashSet::new();
for c in &candidates {
if let Some(stripped) = strip_tool_suffix(c) {
extra.insert(stripped.clone());
extra.insert(norm_separators(&stripped));
extra.insert(camel_to_snake(&stripped));
}
}
candidates.extend(extra);
}
for c in candidates {
if c.is_empty() {
continue;
}
if let Some(canonical) = registry.lookup_tool_name(&c) {
return Some(canonical);
}
}
fuzzy_match_tool_name(registry, &lowered)
}
pub fn resolve_tool_call_name(registry: &ToolRegistry, raw: &str) -> ResolvedToolName {
let original = raw.to_string();
let trimmed = raw.trim();
if let Some(canonical) = registry.lookup_tool_name(trimmed) {
return ResolvedToolName {
original,
canonical,
repaired: false,
};
}
if let Some(canonical) = repair_tool_name(registry, raw) {
return ResolvedToolName {
original,
canonical,
repaired: true,
};
}
let stripped = strip_tool_name_pollution(raw);
let repaired = stripped != trimmed;
ResolvedToolName {
original,
canonical: stripped,
repaired,
}
}
pub fn fuzzy_match_tool_name(registry: &ToolRegistry, name: &str) -> Option<String> {
const CUTOFF: f64 = 0.7;
let mut best: Option<(String, f64)> = None;
for candidate in registry.tool_names() {
let score = strsim::jaro_winkler(name, candidate);
if score >= CUTOFF && best.as_ref().is_none_or(|(_, s)| score > *s) {
best = Some((candidate.to_string(), score));
}
}
best.map(|(name, _)| name)
}
#[cfg(test)]
mod tests {
use super::*;
fn registry() -> ToolRegistry {
ToolRegistry::new()
}
#[test]
fn tn01_clean_name_borrowed_path() {
let reg = registry();
let resolved = resolve_tool_call_name(®, "write_file");
assert_eq!(resolved.canonical, "write_file");
assert!(!resolved.repaired);
}
#[test]
fn tn02_strips_channel_token() {
let reg = registry();
let resolved = resolve_tool_call_name(®, "web_extract<|channel|>commentary");
assert_eq!(resolved.canonical, "web_extract");
}
#[test]
fn tn03_normalizes_spaces_and_hyphens() {
let reg = registry();
assert_eq!(
resolve_tool_call_name(®, "write file").canonical,
"write_file"
);
assert_eq!(
resolve_tool_call_name(®, "web-extract").canonical,
"web_extract"
);
}
#[test]
fn tn04_class_like_patch_tool_suffix() {
let reg = registry();
assert_eq!(repair_tool_name(®, "Patch_tool"), Some("patch".into()));
assert_eq!(repair_tool_name(®, "PatchTool"), Some("patch".into()));
}
#[test]
fn tn05_browser_click_variants() {
let reg = registry();
assert_eq!(
repair_tool_name(®, "BrowserClick_tool"),
Some("browser_click".into())
);
assert_eq!(
repair_tool_name(®, "BrowserClick"),
Some("browser_click".into())
);
}
#[test]
fn tn06_write_file_camel_case() {
let reg = registry();
assert_eq!(
repair_tool_name(®, "WriteFileTool"),
Some("write_file".into())
);
}
#[test]
fn tn07_fuzzy_typo_terminal() {
let reg = registry();
assert_eq!(repair_tool_name(®, "terminall"), Some("terminal".into()));
}
#[test]
fn tn08_xml_pollution_terminal() {
let reg = registry();
let polluted = r#"terminal" parameter="command" string="true"#;
assert_eq!(repair_tool_name(®, polluted), Some("terminal".into()));
}
#[test]
fn tn09_unknown_returns_none() {
let reg = registry();
assert!(repair_tool_name(®, "xyz_no_such_tool").is_none());
}
#[test]
fn tn10_empty_returns_none() {
let reg = registry();
assert!(repair_tool_name(®, "").is_none());
assert!(repair_tool_name(®, "<|channel|>commentary").is_none());
}
}