vtcode_skills/
file_references.rs1use hashbrown::HashSet;
7use regex::Regex;
8use std::path::{Path, PathBuf};
9use std::sync::LazyLock;
10
11static MD_LINK_REGEX: LazyLock<Regex> =
13 LazyLock::new(|| Regex::new(r"\[.*?\]\((.*?)\)").expect("valid markdown link regex"));
14
15static PLAIN_PATH_REGEX: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r#"\b(scripts|references|assets)/[^\s\),\]`\"<>]+"#).expect("valid plain path regex"));
18
19pub struct FileReferenceValidator {
27 skill_root: PathBuf,
28}
29
30impl FileReferenceValidator {
31 pub fn new(skill_root: PathBuf) -> Self {
33 Self { skill_root }
34 }
35
36 pub(crate) fn validate_references(&self, instructions: &str) -> Vec<String> {
40 let mut errors = Vec::new();
41 let references = self.extract_references(instructions);
42
43 for reference in &references {
44 if let Err(e) = self.validate_reference(reference) {
45 errors.push(format!("Invalid reference '{reference}': {e}"));
46 }
47 }
48
49 errors.sort();
50 errors
51 }
52
53 fn extract_references(&self, instructions: &str) -> HashSet<String> {
60 let mut references = HashSet::new();
61 let md_link_regex = &*MD_LINK_REGEX;
62 let plain_path_regex = &*PLAIN_PATH_REGEX;
63
64 let link_ranges = md_link_regex
65 .find_iter(instructions)
66 .map(|matched| matched.range())
67 .collect::<Vec<_>>();
68
69 for cap in md_link_regex.captures_iter(instructions) {
71 if let Some(path_match) = cap.get(1) {
72 let path = path_match.as_str().trim().trim_matches(['<', '>']);
73 if path.starts_with('#')
76 || path.starts_with("//")
77 || path.contains("://")
78 || path.starts_with("mailto:")
79 {
80 continue;
81 }
82 let path = path.split('#').next().unwrap_or(path).trim_end_matches(['`', '.', ';', ':']);
83 references.insert(path.to_string());
84 }
85 }
86
87 for cap in plain_path_regex.captures_iter(instructions) {
89 if let Some(path_match) = cap.get(0) {
90 if link_ranges.iter().any(|range| range.contains(&path_match.start())) {
91 continue;
92 }
93 let token_start = instructions[..path_match.start()]
94 .rfind(char::is_whitespace)
95 .map_or(0, |offset| offset + 1);
96 if instructions[token_start..path_match.start()].contains("://") {
97 continue;
98 }
99 references.insert(path_match.as_str().trim_end_matches(['.', ';', ':']).to_string());
100 }
101 }
102
103 references
104 }
105
106 fn validate_reference(&self, reference: &str) -> Result<(), String> {
108 let path = Path::new(reference);
110
111 if path.is_absolute() {
113 return Err("Absolute paths are not allowed".to_string());
114 }
115
116 let components: Vec<_> = path.components().collect();
118 if components.is_empty() {
119 return Err("Empty path".to_string());
120 }
121
122 if let Some(first_component) = components.first() {
124 let first_dir = first_component.as_os_str().to_string_lossy();
125 if !matches!(first_dir.as_ref(), "scripts" | "references" | "assets") {
126 return Err(format!(
127 "Invalid directory '{first_dir}'. Must be 'scripts/', 'references/', or 'assets/'"
128 ));
129 }
130 }
131
132 if components.len() > 2 {
134 return Err(format!(
135 "Path is too deep: '{reference}'. Per Agent Skills spec, references must be one level deep."
136 ));
137 }
138
139 if components.len() == 2 {
141 let full_path = self.skill_root.join(path);
142 if !full_path.exists() {
143 return Err(format!("Referenced file does not exist: {full_path:?}"));
144 }
145 }
146
147 Ok(())
148 }
149
150 pub fn list_valid_references(&self) -> Vec<PathBuf> {
152 let mut references = Vec::new();
153
154 for subdir in &["scripts", "references", "assets"] {
155 let dir = self.skill_root.join(subdir);
156 if dir.is_dir()
157 && let Ok(entries) = std::fs::read_dir(&dir)
158 {
159 for entry in entries.flatten() {
160 let path = entry.path();
161 if path.is_file() {
162 references.push(path.strip_prefix(&self.skill_root).unwrap_or(&path).to_path_buf());
163 }
164 }
165 }
166 }
167
168 references
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use std::fs;
176 use tempfile::TempDir;
177
178 #[test]
179 fn test_valid_file_references() {
180 let temp_dir = TempDir::new().unwrap();
181 let skill_root = temp_dir.path().to_path_buf();
182
183 fs::create_dir(skill_root.join("scripts")).unwrap();
185 fs::write(skill_root.join("scripts/helper.py"), "# test").unwrap();
186
187 let validator = FileReferenceValidator::new(skill_root);
188 let instructions = r#"
189 See [the reference](references/REFERENCE.md) for details.
190 Run the extraction script: scripts/helper.py
191 "#;
192
193 let errors = validator.validate_references(instructions);
194 assert_eq!(errors.len(), 1);
196 assert!(errors[0].contains("references/REFERENCE.md"));
197 }
198
199 #[test]
200 fn markdown_paths_are_normalized_and_external_links_ignored() {
201 let temp = TempDir::new().unwrap();
202 fs::create_dir(temp.path().join("scripts")).unwrap();
203 fs::write(temp.path().join("scripts/check.sh"), "exit 0").unwrap();
204 let validator = FileReferenceValidator::new(temp.path().to_path_buf());
205 let text = "Run `scripts/check.sh`. Then [check](scripts/check.sh#usage). See [upstream](https://github.com/astral-sh/hawk), [remote asset](https://example.com/assets/image.png), https://example.com/scripts/check.sh, [section](#usage), [mail](mailto:owner@example.com).";
206 assert!(validator.validate_references(text).is_empty());
207 let errors = validator.validate_references(
208 "Run `scripts/missing.sh`. See [missing](scripts/missing.sh). Run `scripts/other.sh`.",
209 );
210 assert_eq!(errors.len(), 2, "inline and linked copies must deduplicate");
211 assert!(errors[0].contains("'scripts/missing.sh'"));
212 assert!(errors[1].contains("'scripts/other.sh'"));
213 assert!(!errors.iter().any(|error| error.contains('`')));
214 }
215
216 #[test]
217 fn test_invalid_directory() {
218 let validator = FileReferenceValidator::new(PathBuf::from("/tmp"));
219 let errors = validator.validate_references("See `scripts/nonexistent.py`");
221 assert!(!errors.is_empty());
222 assert!(errors[0].contains("nonexistent.py"));
223 }
224
225 #[test]
226 fn test_deep_path_error() {
227 let validator = FileReferenceValidator::new(PathBuf::from("/tmp"));
228 let errors = validator.validate_references("See `scripts/subdir/deep.py`");
229 assert!(!errors.is_empty());
230 assert!(errors[0].contains("too deep"));
231 }
232
233 #[test]
234 fn test_list_valid_references() {
235 let temp_dir = TempDir::new().unwrap();
236 let skill_root = temp_dir.path().to_path_buf();
237
238 fs::create_dir(skill_root.join("scripts")).unwrap();
239 fs::create_dir(skill_root.join("references")).unwrap();
240 fs::write(skill_root.join("scripts/test.py"), "# test").unwrap();
241 fs::write(skill_root.join("references/ref.md"), "# ref").unwrap();
242
243 let validator = FileReferenceValidator::new(skill_root);
244 let refs = validator.list_valid_references();
245
246 assert_eq!(refs.len(), 2);
247 assert!(refs.iter().any(|p| p.to_string_lossy() == "scripts/test.py"));
248 assert!(refs.iter().any(|p| p.to_string_lossy() == "references/ref.md"));
249 }
250}