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