use std::collections::HashSet;
use std::fs;
use crate::skills::{Skill, SkillMode};
pub fn glob_matches(pattern: &str, path: &str) -> bool {
match_glob(pattern, path)
}
fn match_glob(pat: &str, s: &str) -> bool {
if pat.is_empty() {
return s.is_empty();
}
if let Some(rest) = pat.strip_prefix("**/") {
if match_glob(rest, s) {
return true;
}
let mut idx = 0;
for ch in s.chars() {
idx += ch.len_utf8();
if ch == '/' && match_glob(rest, &s[idx..]) {
return true;
}
}
return false;
}
if pat == "**" {
return true;
}
if let Some(rest) = pat.strip_prefix('*') {
let s_bytes = s.as_bytes();
for i in 0..=s.len() {
if i > 0 && s_bytes[i - 1] == b'/' {
break; }
if match_glob(rest, &s[i..]) {
return true;
}
}
return false;
}
let mut pat_chars = pat.chars();
let mut s_chars = s.chars();
match (pat_chars.next(), s_chars.next()) {
(Some(pc), Some(sc)) if pc == sc => match_glob(pat_chars.as_str(), s_chars.as_str()),
(None, None) => true,
_ => false,
}
}
pub struct SkillInjector {
globs_skills: Vec<Skill>,
already_injected: HashSet<String>,
}
impl SkillInjector {
pub fn new(skills: &[Skill]) -> Self {
let globs_skills = skills
.iter()
.filter(|s| s.mode == SkillMode::Globs && s.globs.is_some())
.cloned()
.collect();
Self {
globs_skills,
already_injected: HashSet::new(),
}
}
pub fn check(&mut self, tool_results: &[String]) -> Vec<(String, String)> {
let paths = extract_paths(tool_results);
let mut injected = Vec::new();
for skill in &self.globs_skills {
if self.already_injected.contains(&skill.name) {
continue;
}
let patterns = match &skill.globs {
Some(g) if !g.is_empty() => g,
_ => continue,
};
let matches = paths
.iter()
.any(|p| patterns.iter().any(|pat| glob_matches(pat, p)));
if matches {
if let Ok(content) = fs::read_to_string(&skill.path) {
let body = crate::skills::extract_skill_body(&content);
injected.push((skill.name.clone(), body.to_string()));
self.already_injected.insert(skill.name.clone());
}
}
}
injected
}
}
fn extract_paths(results: &[String]) -> Vec<&str> {
let mut paths = Vec::new();
for result in results {
for token in result.split_whitespace() {
let token = token.trim_end_matches([',', '.', ';', ':', '"', '\'', ')']);
let token = token.trim_start_matches(['"', '\'', '(']);
if token.contains('/')
&& !token.starts_with("http://")
&& !token.starts_with("https://")
{
paths.push(token);
}
}
}
paths
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_matches_exact() {
assert!(glob_matches("src/runtime.rs", "src/runtime.rs"));
assert!(!glob_matches("src/runtime.rs", "src/agent.rs"));
}
#[test]
fn glob_matches_double_star() {
assert!(glob_matches("src/tools/**", "src/tools/fs.rs"));
assert!(glob_matches("src/tools/**", "src/tools/sub/dir/file.rs"));
assert!(!glob_matches("src/tools/**", "src/agent.rs"));
}
#[test]
fn glob_matches_single_star() {
assert!(glob_matches("src/*.rs", "src/agent.rs"));
assert!(!glob_matches("src/*.rs", "src/tools/fs.rs"));
}
#[test]
fn glob_matches_star_at_end() {
assert!(glob_matches("src/tools/**", "src/tools/"));
}
#[test]
fn glob_no_match_different_prefix() {
assert!(!glob_matches("src/tools/**", "tests/integration.rs"));
assert!(!glob_matches("src/llm/**", "src/tools/fs.rs"));
}
#[test]
fn extract_paths_finds_file_paths() {
let results = vec![
"Updated file src/tools/fs.rs successfully.".to_string(),
"No changes to src/agent.rs".to_string(),
];
let paths = extract_paths(&results);
assert!(paths.contains(&"src/tools/fs.rs"));
assert!(paths.contains(&"src/agent.rs"));
}
#[test]
fn extract_paths_ignores_urls() {
let results = vec!["See https://example.com/docs for more.".to_string()];
let paths = extract_paths(&results);
assert!(paths.is_empty());
}
#[test]
fn extract_paths_also_ignores_http_urls() {
let results = vec!["See http://example.com/docs for details.".to_string()];
let paths = extract_paths(&results);
assert!(
paths.is_empty(),
"http:// URLs must be excluded; got {paths:?}"
);
}
#[test]
fn extract_paths_strips_surrounding_quotes_and_punctuation() {
let results = vec!["Path: \"src/foo/bar.rs\", done.".to_string()];
let paths = extract_paths(&results);
assert!(
paths.contains(&"src/foo/bar.rs"),
"stripped path must be present; got: {paths:?}"
);
}
#[test]
fn extract_paths_excludes_tokens_without_slash() {
let results = vec!["plain-token and src/real/path.rs here".to_string()];
let paths = extract_paths(&results);
assert!(
!paths.contains(&"plain-token"),
"token without slash must not be included"
);
assert!(
paths.contains(&"src/real/path.rs"),
"token with slash must be included"
);
}
#[test]
fn glob_lone_double_star_matches_everything() {
assert!(glob_matches("**", ""), "** must match empty string");
assert!(
glob_matches("**", "any/path/at/all"),
"** must match any path"
);
assert!(glob_matches("**", "src/foo.rs"), "** must match files");
}
#[test]
fn glob_empty_pattern_matches_empty_string_only() {
assert!(glob_matches("", ""), "empty pattern matches empty string");
assert!(
!glob_matches("", "non-empty"),
"empty pattern must not match non-empty string"
);
}
#[test]
fn glob_matches_double_star_in_middle() {
assert!(
glob_matches("src/**/mod.rs", "src/a/b/mod.rs"),
"** must cross multiple dirs"
);
assert!(
!glob_matches("src/**/mod.rs", "src/a/b/foo.rs"),
"** should not match wrong filename"
);
}
}