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