Skip to main content

vtcode_core/skills/
skill_file_tracker.rs

1//! # Skill File Tracker
2//!
3//! Generic file tracking for ALL skills, not just execute_code.
4//! Automatically intercepts skill execution and verifies any files mentioned in output.
5
6use crate::tools::file_tracker::FileTracker;
7use anyhow::Result;
8use hashbrown::HashSet;
9use regex::Regex;
10use std::path::PathBuf;
11
12/// Generic file tracker that works with any skill output
13pub struct SkillFileTracker {
14    workspace_root: PathBuf,
15    file_tracker: FileTracker,
16    file_patterns: Vec<Regex>,
17}
18
19impl SkillFileTracker {
20    pub fn new(workspace_root: PathBuf) -> Self {
21        let file_tracker = FileTracker::new(workspace_root.clone());
22
23        // Common file patterns in skill output
24        let patterns = [
25            // Pattern: "file.ext", 'file.ext', or just file.ext
26            "['\"]?([\\w.-]+\\.(?:pdf|xlsx|csv|docx|png|jpg|json|xml|txt|md))['\"]?",
27            // Pattern: path/to/file.ext
28            "['\"]?([\\w/\\\\.-]+\\.(?:pdf|xlsx|csv|docx|png|jpg|json|xml|txt|md))['\"]?",
29            // Pattern: Generated: filename
30            "(?:[Gg]enerated|[Cc]reated):\\s*([\\w.-]+\\.(?:pdf|xlsx|csv|docx|png|jpg|json|xml|txt|md))",
31            // Pattern: Output saved to filename
32            "[Oo]utput (?:saved|written) to:?(?:\\s*)([\\w.-]+\\.(?:pdf|xlsx|csv|docx|png|jpg|json|xml|txt|md))",
33        ]
34        .into_iter()
35        .filter_map(|pattern| Regex::new(pattern).ok())
36        .collect();
37
38        Self {
39            workspace_root,
40            file_tracker,
41            file_patterns: patterns,
42        }
43    }
44
45    /// Scan skill output for file references and verify their existence
46    pub async fn scan_and_verify_skill_output(
47        &self,
48        output: &str,
49    ) -> Result<SkillFileVerification> {
50        let mut detected_files = HashSet::new();
51
52        // Extract potential filenames from output
53        for pattern in &self.file_patterns {
54            for capture in pattern.captures_iter(output) {
55                if let Some(filename) = capture
56                    .get(1)
57                    .map(|m| m.as_str())
58                    .filter(|f| !Self::is_false_positive(f))
59                {
60                    detected_files.insert(filename.to_string());
61                }
62            }
63        }
64
65        // Verify each detected file
66        let mut verified_files = Vec::new();
67        let mut missing_files = Vec::new();
68
69        for filename in detected_files {
70            match self.file_tracker.verify_file_exists(&filename).await? {
71                Some(file_info) => {
72                    verified_files.push(VerifiedFile {
73                        filename: filename.clone(),
74                        absolute_path: file_info.absolute_path,
75                        size: file_info.size,
76                        status: FileStatus::Found,
77                    });
78                }
79                None => {
80                    // Try alternative: maybe it's in a subdirectory
81                    let alt_path = self.find_alternative_location(&filename).await?;
82                    if let Some(alt_file) = alt_path {
83                        verified_files.push(VerifiedFile {
84                            filename: filename.clone(),
85                            absolute_path: alt_file.absolute_path,
86                            size: alt_file.size,
87                            status: FileStatus::FoundAlternative,
88                        });
89                    } else {
90                        missing_files.push(MissingFile {
91                            filename: filename.clone(),
92                            attempted_locations: vec![self.workspace_root.join(&filename)],
93                            suggestions: self.generate_suggestions(&filename),
94                        });
95                    }
96                }
97            }
98        }
99
100        let summary = self.generate_verification_summary(&verified_files, &missing_files);
101        let suggestion = self.generate_user_suggestion(&verified_files, &missing_files);
102
103        Ok(SkillFileVerification {
104            verified_files,
105            missing_files,
106            summary,
107            suggestion,
108        })
109    }
110
111    /// Post-process skill output to add file verification information
112    pub async fn enhance_skill_output(&self, original_output: String) -> Result<String> {
113        let verification = self.scan_and_verify_skill_output(&original_output).await?;
114
115        if verification.verified_files.is_empty() && verification.missing_files.is_empty() {
116            // No files detected, return original output
117            return Ok(original_output);
118        }
119
120        let enhanced_output = format!("{original_output}\n\n{}", verification.summary);
121
122        Ok(enhanced_output)
123    }
124
125    /// Find file in alternative locations (subdirectories, etc.)
126    async fn find_alternative_location(&self, filename: &str) -> Result<Option<TrackedFile>> {
127        // Search in common subdirectories
128        let subdirs = vec!["output", "results", "generated", "dist", "build", "tmp"];
129
130        for subdir in subdirs {
131            let alt_path = self.workspace_root.join(subdir).join(filename);
132            if let Some(file_info) = self.verify_file_at_path(&alt_path).await? {
133                return Ok(Some(file_info));
134            }
135        }
136
137        // Search entire workspace recursively
138        let pattern = format!("**/{}", filename);
139        if let Some(path) = self
140            .file_tracker
141            .find_files_matching_pattern(&pattern)
142            .await
143            .ok()
144            .and_then(|mut files| files.pop())
145            && let Ok(Some(file_info)) = self.verify_file_at_path(&path).await
146        {
147            return Ok(Some(file_info));
148        }
149
150        Ok(None)
151    }
152
153    /// Verify file at specific path
154    async fn verify_file_at_path(&self, path: &PathBuf) -> Result<Option<TrackedFile>> {
155        if let Some(metadata) = tokio::fs::metadata(path).await.ok().filter(|m| m.is_file()) {
156            return Ok(Some(TrackedFile {
157                absolute_path: path.clone(),
158                size: metadata.len(),
159                modified: metadata.modified().unwrap_or(std::time::SystemTime::now()),
160            }));
161        }
162        Ok(None)
163    }
164
165    /// Generate suggestions for missing files
166    fn generate_suggestions(&self, filename: &str) -> Vec<String> {
167        vec![
168            format!("Check if '{}' was created with a different name", filename),
169            "Verify the skill execution completed successfully".to_string(),
170            "Check subdirectories like 'output/', 'generated/', or 'dist/'".to_string(),
171            format!("Run 'find . -name \"{}\"' to search for the file", filename),
172        ]
173    }
174
175    /// Check if filename is a false positive
176    fn is_false_positive(filename: &str) -> bool {
177        let false_positives = vec![
178            "example.pdf",
179            "template.xlsx",
180            "sample.csv", // Template names
181            "Cargo.toml",
182            "package.json",
183            "go.mod", // Config files
184            "README.md",
185            "LICENSE.txt",
186            ".gitignore", // Project files
187        ];
188
189        false_positives.contains(&filename) || filename.starts_with('.')
190    }
191
192    /// Generate summary of verification results
193    fn generate_verification_summary(
194        &self,
195        verified: &[VerifiedFile],
196        missing: &[MissingFile],
197    ) -> String {
198        let mut summary = String::new();
199
200        if !verified.is_empty() {
201            summary.push_str("v Generated Files:\n");
202            for file in verified {
203                match file.status {
204                    FileStatus::Found => {
205                        summary.push_str(&format!(
206                            "   ✓ {} → {} ({} bytes)\n",
207                            file.filename,
208                            file.absolute_path.display(),
209                            file.size
210                        ));
211                    }
212                    FileStatus::FoundAlternative => {
213                        summary.push_str(&format!(
214                            "   ✓ {} → {} ({} bytes) [found in alternative location]\n",
215                            file.filename,
216                            file.absolute_path.display(),
217                            file.size
218                        ));
219                    }
220                }
221            }
222        }
223
224        if !missing.is_empty() {
225            if !summary.is_empty() {
226                summary.push('\n');
227            }
228            summary.push_str("[!]  Missing Files:\n");
229            for file in missing {
230                summary.push_str(&format!("   ✗ {}\n", file.filename));
231                for suggestion in &file.suggestions {
232                    summary.push_str(&format!("     • {}\n", suggestion));
233                }
234            }
235        }
236
237        summary
238    }
239
240    /// Generate user-friendly suggestion
241    fn generate_user_suggestion(
242        &self,
243        verified: &[VerifiedFile],
244        missing: &[MissingFile],
245    ) -> String {
246        if missing.is_empty() && verified.len() == 1 {
247            format!("File generated at: {}", verified[0].absolute_path.display())
248        } else if missing.is_empty() && !verified.is_empty() {
249            format!("{} files generated successfully", verified.len())
250        } else if !missing.is_empty() && verified.is_empty() {
251            "Some files could not be found. Please check the output above.".to_string()
252        } else {
253            format!(
254                "Generated {} files, {} files missing. See summary above.",
255                verified.len(),
256                missing.len()
257            )
258        }
259    }
260}
261
262impl From<crate::tools::file_tracker::TrackedFile> for TrackedFile {
263    fn from(file: crate::tools::file_tracker::TrackedFile) -> Self {
264        Self {
265            absolute_path: file.absolute_path,
266            size: file.size,
267            modified: file.modified,
268        }
269    }
270}
271
272/// Verification result for skill-generated files
273#[derive(Debug, Clone)]
274pub struct SkillFileVerification {
275    pub verified_files: Vec<VerifiedFile>,
276    pub missing_files: Vec<MissingFile>,
277    pub summary: String,
278    pub suggestion: String,
279}
280
281/// Verified file information
282#[derive(Debug, Clone)]
283pub struct VerifiedFile {
284    pub filename: String,
285    pub absolute_path: PathBuf,
286    pub size: u64,
287    pub status: FileStatus,
288}
289
290/// File verification status
291#[derive(Debug, Clone, PartialEq)]
292pub enum FileStatus {
293    Found,
294    FoundAlternative,
295}
296
297/// Missing file information
298#[derive(Debug, Clone)]
299pub struct MissingFile {
300    pub filename: String,
301    pub attempted_locations: Vec<PathBuf>,
302    pub suggestions: Vec<String>,
303}
304
305/// Tracked file (simplified from file_tracker::TrackedFile)
306#[derive(Debug, Clone)]
307pub struct TrackedFile {
308    pub absolute_path: PathBuf,
309    pub size: u64,
310    pub modified: std::time::SystemTime,
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use tempfile::TempDir;
317
318    #[tokio::test]
319    async fn test_skill_file_scanning() {
320        let temp_dir = TempDir::new().unwrap();
321        let tracker = SkillFileTracker::new(temp_dir.path().to_path_buf());
322
323        // Test output with file references
324        let output = r#"
325Generated PDF report: quarterly_report.pdf
326Also created summary.csv with key metrics.
327Output saved to: chart.png
328"#;
329
330        let result = tracker.scan_and_verify_skill_output(output).await.unwrap();
331        assert_eq!(result.verified_files.len(), 0); // No files actually created
332        assert_eq!(result.missing_files.len(), 3); // All detected but missing
333
334        let missing_names: Vec<String> = result
335            .missing_files
336            .iter()
337            .map(|m| m.filename.clone())
338            .collect();
339
340        assert!(missing_names.contains(&"quarterly_report.pdf".to_string()));
341        assert!(missing_names.contains(&"summary.csv".to_string()));
342        assert!(missing_names.contains(&"chart.png".to_string()));
343    }
344
345    #[tokio::test]
346    async fn test_enhance_skill_output() {
347        let temp_dir = TempDir::new().unwrap();
348        let tracker = SkillFileTracker::new(temp_dir.path().to_path_buf());
349
350        let original = "Generated: report.pdf".to_string();
351        let enhanced = tracker
352            .enhance_skill_output(original.clone())
353            .await
354            .unwrap();
355
356        assert!(enhanced.contains("Generated: report.pdf"));
357        assert!(enhanced.contains("Generated Files") || enhanced.contains("Missing Files"));
358    }
359
360    #[test]
361    fn test_false_positive_detection() {
362        assert!(SkillFileTracker::is_false_positive("Cargo.toml"));
363        assert!(SkillFileTracker::is_false_positive("README.md"));
364        assert!(!SkillFileTracker::is_false_positive("report.pdf"));
365        assert!(!SkillFileTracker::is_false_positive("my_chart.png"));
366    }
367}